From cf2e18411244f837999bcf934ba383a21ec8661d Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 23 Jul 2026 03:06:57 +0800 Subject: [PATCH 01/70] feat(telemetry): session-telemetry seam with mandatory redaction + OTel backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revive the reviewed session-telemetry packages from the closed session-telemetry-otlp-rfc branch (PR #222/#231) on current master, renamed to @deepseek-ai/dsh-session-telemetry{,-otel} (the SDK component-telemetry package holds the dsh-telemetry name). Delta over the branch version: every record now passes a telemetry/redact waterfall between projection and emit() — the innermost next() applies a non-configurable conservative credential-shape rule set, listeners stack stricter rules, a throwing rule withholds the record fail-closed, and the canonical log is never rewritten. This answers the export-side concern that closed PR #222; the boundary axiom (our aspect ends at emit(); delivery is the reporting SDK's) is unchanged, and the runtime-telemetry RFC's outbox / readCommitted lane is recorded as deferred in the Agent Note. Covered by seam/redact/OTel-wire unit tiers (100% per-file) and a keyless Loader-composition e2e that boots the examples fixture against a mock OTLP collector and pins redaction on the wire plus the untouched canonical log. --- ...3-session-telemetry-otel-revival.i18n.yaml | 6 + ...26-07-23-session-telemetry-otel-revival.md | 33 ++ ...07-23-session-telemetry-otel-revival.zh.md | 33 ++ docs/capability-seams.md | 6 + docs/config-catalog.md | 32 ++ docs/cordis-catalog/events.md | 27 ++ docs/cordis-catalog/services.md | 23 ++ docs/event-producer-consumer.md | 9 +- docs/module-graph.md | 13 + .../tests/fixtures/telemetry-otel-driver.ts | 43 +++ .../tests/fixtures/telemetry-otel.cordis.yml | 23 ++ examples/package.json | 1 + knip.json | 11 + packages/README.md | 7 +- .../cordis/tool-cordis/src/api-catalog.ts | 33 ++ packages/telemetry/README.md | 8 + .../session-telemetry-otel/README.md | 39 +++ .../session-telemetry-otel/package.json | 52 +++ .../session-telemetry-otel/src/index.ts | 168 ++++++++++ .../session-telemetry-otel/src/invariant.ts | 32 ++ .../tests/loader-composition.e2e.ts | 98 ++++++ .../session-telemetry-otel/tests/otel.e2e.ts | 25 ++ .../session-telemetry-otel/tests/otel.spec.ts | 166 ++++++++++ .../session-telemetry-otel/tsconfig.json | 33 ++ .../telemetry/session-telemetry/README.md | 40 +++ .../telemetry/session-telemetry/package.json | 41 +++ .../session-telemetry/src/coordinator.ts | 244 ++++++++++++++ .../telemetry/session-telemetry/src/index.ts | 145 ++++++++ .../session-telemetry/src/invariant.ts | 32 ++ .../telemetry/session-telemetry/src/redact.ts | 77 +++++ .../session-telemetry/tests/redact.spec.ts | 157 +++++++++ .../session-telemetry/tests/telemetry.spec.ts | 313 ++++++++++++++++++ .../telemetry/session-telemetry/tsconfig.json | 27 ++ pnpm-lock.yaml | 200 +++++++++++ scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 10 + .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 2 + tsconfig.host.json | 2 + 39 files changed, 2207 insertions(+), 7 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md create mode 100644 examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts create mode 100644 examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml create mode 100644 packages/telemetry/README.md create mode 100644 packages/telemetry/session-telemetry-otel/README.md create mode 100644 packages/telemetry/session-telemetry-otel/package.json create mode 100644 packages/telemetry/session-telemetry-otel/src/index.ts create mode 100644 packages/telemetry/session-telemetry-otel/src/invariant.ts create mode 100644 packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts create mode 100644 packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts create mode 100644 packages/telemetry/session-telemetry-otel/tests/otel.spec.ts create mode 100644 packages/telemetry/session-telemetry-otel/tsconfig.json create mode 100644 packages/telemetry/session-telemetry/README.md create mode 100644 packages/telemetry/session-telemetry/package.json create mode 100644 packages/telemetry/session-telemetry/src/coordinator.ts create mode 100644 packages/telemetry/session-telemetry/src/index.ts create mode 100644 packages/telemetry/session-telemetry/src/invariant.ts create mode 100644 packages/telemetry/session-telemetry/src/redact.ts create mode 100644 packages/telemetry/session-telemetry/tests/redact.spec.ts create mode 100644 packages/telemetry/session-telemetry/tests/telemetry.spec.ts create mode 100644 packages/telemetry/session-telemetry/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml new file mode 100644 index 0000000000..31a5914d0d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -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 +2026-07-23-session-telemetry-otel-revival.md: 1150d363e9db98a39e1188b86dc41fa291edba41 +2026-07-23-session-telemetry-otel-revival.zh.md: 76749a59c38ef2b8c5b3f09960ca500c25d8e1a8 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md new file mode 100644 index 0000000000..1150d363e9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -0,0 +1,33 @@ +# Agent Note: Session telemetry seam with mandatory redaction and the OTel backend + +Status: implemented + +English | [中文](2026-07-23-session-telemetry-otel-revival.zh.md) + +## Problem + +Every deployment that wants harness sessions in an observability stack must hand-roll a session-log consumer: subscription, lifecycle handoff, and — hardest — redaction, since the raw log carries file contents and command output that may embed credentials. A telemetry seam and OTel backend shipped once on the `session-telemetry-otlp-rfc` branch (PR #222/#231) but never reached master: the proposal exported raw session events verbatim, which legal review declined. The capture-side design (backend contract, coordinator, handoff cursor, chunk projection) was sound and reviewed; the export-side stance was the blocker. + +## Decision + +`packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go, and nothing crosses the seam unredacted: + +- **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: adoption with cursor read-back, the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), the fixed first-chunk-per-(turn, step) projection, the `agent/error` relay, and dispose-time `shutdown` records. +- **The `telemetry/redact` waterfall** — the delta over the branch version. Every record passes it before reaching any backend; the innermost `next()` applies a conservative built-in rule set (credential shapes: API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo), deployments stack stricter rules as listeners, and a throwing rule withholds the record fail-closed. The pattern list is a security invariant, deliberately not configurable. Redaction applies to the exported copy only; the canonical log is never rewritten. +- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `exporter.url` is required and validated at load; unmounted or unconfigured, nothing leaves the process. + +The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs — delivery is best-effort (at-most-once across a crash), which the READMEs state plainly. + +## Alternatives considered + +**Implement the runtime-telemetry RFC's outbox (durable spool, per-sink cursors, at-least-once, a `readCommitted` persistence-seam method).** Deferred, not rejected: the SDK stance makes delivery semantics the reporting SDK's territory, and the OTel SDK's own batch pipeline is the honest default. The outbox is a pure additive layer (the `emit()` contract does not move); revive it when a deployment states a crash-loss requirement telemetry must satisfy. + +**Export without built-in redaction, delegating to receiver-side collector processors.** Rejected — this is what legal declined. Receiver-side redaction ships the secret first and scrubs it second; the seam must scrub before bytes leave the process, and a waterfall makes the redaction point auditable and stackable. + +**A configurable pattern list for the default rules.** Rejected: deployment-varying tunables belong in config, but a security invariant does not — weakening the floor should require code, not YAML. Stricter rules stack as `telemetry/redact` listeners. + +**Map onto OTel spans (GenAI semantic conventions) instead of logs.** Rejected for this revival: the branch implementation's log mapping is reviewed and shipped-shaped; the span model is lossy for forkable, interruptible sessions and belongs to a future consumer with real span queries to serve. + +## Consequences + +A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. Credential-shaped substrings never leave the process even on a rule-free deployment, at the cost of a synchronous per-record scrub on the capture path (string-regex over lossless-JSON bodies — bounded by event size, no I/O). Exported bodies can differ from canonical log bytes wherever the placeholder landed, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md new file mode 100644 index 0000000000..76749a59c3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Session telemetry seam with mandatory redaction and the OTel backend + +Status: implemented + +[English](2026-07-23-session-telemetry-otel-revival.md) | 中文 + +## Problem + +每个想把 harness 会话接入可观测性体系的部署方都得手写一套会话日志消费端:订阅、生命周期交接、以及最难的脱敏——原始日志携带文件内容与命令输出,可能内嵌凭据。遥测 seam 和 OTel backend 曾在 `session-telemetry-otlp-rfc` 分支(PR #222/#231)上完成过一版,但从未进入 master:该提案将原始会话事件原样导出,法务评审未予通过。捕获侧设计(backend 契约、coordinator、handoff 游标、chunk 投影)本身合理且经过评审;导出侧的立场才是阻塞点。 + +## Decision + +`packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向,且任何数据未经脱敏不得跨越 seam: + +- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的收养、逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、固定的每 (turn, step) 首 chunk 投影、`agent/error` 转发、以及 dispose 时的 `shutdown` 记录。 +- **`telemetry/redact` waterfall** —— 相对分支版本的增量。每条记录抵达任何 backend 前必经此处;最内层 `next()` 应用保守的内置规则集(凭据形状:API key、GitHub/Slack token、AWS/Google key、JWT、PEM 块、URL userinfo),部署方以监听器堆叠更严规则,抛异常的规则将该记录 fail-closed 扣下。模式列表是安全不变量,刻意不可配置。脱敏只作用于导出副本;canonical log 永不改写。 +- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程。 + +边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。 + +## Alternatives considered + +**实现 runtime-telemetry RFC 的 outbox(落盘 spool、每 sink 游标、at-least-once、persistence seam 的 `readCommitted` 方法)。** 推迟而非否决:SDK 立场使投递语义归属 reporting SDK,OTel SDK 自身的批处理管线是诚实的默认。outbox 是纯增量层(`emit()` 契约不动);待某个部署提出遥测必须满足的崩溃丢失要求时再复活。 + +**不带内置脱敏直接导出,交给接收端 collector processor。** 否决——这正是法务否掉的方案。接收端脱敏是先把秘密发出去再擦除;seam 必须在字节离开进程前擦除,且 waterfall 使脱敏点可审计、可堆叠。 + +**默认规则的模式列表做成可配置。** 否决:随部署变化的调优项应进 config,但安全不变量不应——削弱底线应当需要改代码而非改 YAML。更严格的规则以 `telemetry/redact` 监听器堆叠。 + +**映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 + +## Consequences + +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。即使部署方未配置任何规则,凭据形状的子串也绝不离开进程,代价是捕获路径上每条记录一次同步擦除(对 lossless-JSON body 做字符串正则——受事件大小约束,无 I/O)。导出的 body 在占位符落点处可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a27d325e31..25406c8c9b 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -36,6 +36,9 @@ flowchart LR pkg_hooks_claude["hooks-claude"] pkg_hooks_codex["hooks-codex"] pkg_acp["acp"] + pkg_session_telemetry["session-telemetry"] + svc_telemetry["ctx.telemetry
Session telemetry seam"] + pkg_session_telemetry_otel["session-telemetry-otel"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"] @@ -159,6 +162,8 @@ flowchart LR pkg_session_query --> svc_sessionQuery pkg_session_query_sqlite --> svc_sessionQuery pkg_session_reference --> svc_sessionReferences + pkg_session_telemetry --> svc_telemetry + pkg_session_telemetry_otel --> svc_telemetry pkg_session_title --> svc_sessionTitle pkg_session_title_all_messages_llm --> svc_sessionTitle pkg_session_title_first_message_llm --> svc_sessionTitle @@ -276,6 +281,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49a0a1510f..2f55f60bec 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1047,6 +1047,37 @@ export interface Config { Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) +## `@deepseek-ai/dsh-session-telemetry-otel` + +Requires: `sessions` + +```ts config-catalog +/** + * Plugin configuration: two verbatim SDK option shapes plus nothing else. + * `exporter.url` is the one field this package validates itself — required, + * no default, must parse as an `http(s)` URL — because a missing endpoint + * must fail at plugin load, not at first export. + */ +export interface Config { + /** Passed verbatim to the SDK's OTLP/HTTP log exporter. */ + exporter?: { + /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ + url?: string + /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */ + headers?: Record + } + /** + * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, + * which this plugin fills); the SDK owns and documents these knobs. + */ + processor?: Omit +} +``` + +Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) + +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:39`](../packages/telemetry/session-telemetry-otel/src/index.ts) + ## `@deepseek-ai/dsh-session-title` Requires: `sessions` @@ -1958,6 +1989,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts)) +- `@deepseek-ai/dsh-session-telemetry` ([`packages/telemetry/session-telemetry/src/index.ts`](../packages/telemetry/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm` ([`packages/session-title/session-title-llm/src/index.ts`](../packages/session-title/session-title-llm/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) - `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ed9bebdc92..4ed5de022b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -803,6 +803,33 @@ Emitted when any prompt provider changes. This registry notification is unfilter Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts) +## `telemetry/*` + +### `telemetry/redact` — waterfall + +Redact one outbound record before it reaches the backend. The innermost `next()` applies the seam's conservative default rule set (credential-shape scrubbing); listeners stack stricter rules by transforming its return value, and returning without `next()` replaces the default — the exported record is then only as clean as the replacing rule. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten. + +```ts cordis-catalog +/** + * Redact one outbound record before it reaches the backend. The innermost + * `next()` applies the seam's conservative default rule set + * (credential-shape scrubbing); listeners stack stricter rules by + * transforming its return value, and returning without `next()` replaces + * the default — the exported record is then only as clean as the + * replacing rule. Dispatched synchronously on the capture hot path inside + * the coordinator's containment: a throwing listener withholds that one + * record (fail-closed) and never reaches the agent loop. Redaction + * applies to the exported copy only; the canonical session log is never + * rewritten. + * @param record - the candidate record, already the coordinator's own deep + * copy; listeners return a (possibly new) record and must not mutate it. + * @mode waterfall + */ +'telemetry/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord +``` + +Source: [`packages/telemetry/session-telemetry/src/index.ts:39`](../../packages/telemetry/session-telemetry/src/index.ts) + ## `tools/*` ### `tools/change` — emit diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0d6cc97086..22120b2f8e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1533,6 +1533,29 @@ Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-da Source: [`packages/tasks/tasks/src/index.ts:77`](../../packages/tasks/tasks/src/index.ts) +## `ctx.telemetry` — `Telemetry` (abstract seam) + +The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. + +```ts cordis-catalog +/** + * See {@link TelemetryBackend.emit} — the seam declaration is the contract's one home. + * @param record - the logical record to report; owned by the backend after the call. + */ +abstract emit(record: TelemetryRecord): void + +/** See {@link TelemetryBackend.flush}. */ +flush?(): void + +/** + * See {@link TelemetryBackend.shutdown}. + * @returns resolves when the backend's pipeline has quiesced. + */ +abstract shutdown(): Promise +``` + +Source: [`packages/telemetry/session-telemetry/src/index.ts:123`](../../packages/telemetry/session-telemetry/src/index.ts) + ## `ctx.tokenMeter` — `TokenMeterService` Replay owner for one service-wide estimator and isolated per-session folds. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b36d390f55..30380261fe 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -11,7 +11,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | @@ -33,16 +33,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `telemetry/redact` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:39`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:143`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 212fd723a7..a66c5edc0a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -199,6 +199,10 @@ flowchart TD pkg_tasks["tasks"] pkg_tool_tasks["tool-tasks"] end + subgraph group_telemetry["packages/telemetry"] + pkg_session_telemetry["session-telemetry"] + pkg_session_telemetry_otel["session-telemetry-otel"] + end subgraph group_workflow["packages/workflow"] pkg_tool_ralph["tool-ralph"] pkg_tool_workflow["tool-workflow"] @@ -408,6 +412,9 @@ flowchart TD pkg_tasks --> pkg_invariants pkg_tasks --> pkg_session pkg_tasks --> pkg_timeout + pkg_session_telemetry --> pkg_agent + pkg_session_telemetry --> pkg_invariants + pkg_session_telemetry --> pkg_session pkg_workflow --> pkg_agent pkg_workflow --> pkg_brand pkg_workflow --> pkg_invariants @@ -472,6 +479,10 @@ flowchart TD pkg_pty_local --> pkg_sandbox pkg_pty_local --> pkg_sandbox_policy pkg_pty_local --> pkg_session + pkg_session_telemetry_otel --> pkg_invariants + pkg_session_telemetry_otel --> pkg_llm + pkg_session_telemetry_otel --> pkg_session + pkg_session_telemetry_otel --> pkg_session_telemetry pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -858,6 +869,7 @@ flowchart TD | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | @@ -870,6 +882,7 @@ flowchart TD | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session) | +| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts new file mode 100644 index 0000000000..02be1a9011 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env node +/** + * Test driver: start a mock OTLP/HTTP collector, boot the telemetry Loader + * composition against it, run one turn whose prompt carries a fixture + * credential, then persist everything the collector captured to + * `./otlp-captures.json` for the e2e's inspect step. + */ + +import { writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { once } from 'node:events' +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('telemetry-otel driver requires a config path') + +const captures: unknown[] = [] +const server = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', chunk => chunks.push(chunk as Buffer)) + request.on('end', () => { + captures.push(JSON.parse(Buffer.concat(chunks).toString())) + response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + }) +}) +server.listen(0, '127.0.0.1') +await once(server, 'listening') +const address = server.address() +if (address === null || typeof address === 'string') throw new Error('collector has no port') +process.env.DSH_TELEMETRY_E2E_URL = `http://127.0.0.1:${address.port}/v1/logs` + +const ctx = await boot('telemetry-otel-e2e', resolveConfigPath(configPath, undefined)) +try { + // The fixture credential rides the model-visible user message; the exported + // copy must scrub it while the canonical log keeps the original bytes. + await runOneShot(ctx, { task: 'prove telemetry with key sk-e2efixture1234567890' }) +} finally { + await ctx.fiber.dispose() +} +await writeFile('./otlp-captures.json', JSON.stringify(captures)) +server.close() +server.closeAllConnections() diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml new file mode 100644 index 0000000000..defde98ff5 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -0,0 +1,23 @@ +# Test-only composition: session-telemetry-otel through the real Loader/app +# path, exporting to the mock OTLP collector the driver starts (url via env). +- id: cli-mock-llm + name: './cli-mock-llm.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: telemetry-otel + name: '@deepseek-ai/dsh-session-telemetry-otel' + config: + exporter: + url: !!js process.env.DSH_TELEMETRY_E2E_URL + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: cli-mock + model: cli-mock + persona: 'Test the session-telemetry-otel plugin.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/package.json b/examples/package.json index bd87263340..133196aa96 100644 --- a/examples/package.json +++ b/examples/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", + "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-tui-demo": "workspace:*", diff --git a/knip.json b/knip.json index 59658e1df6..728ded39b7 100644 --- a/knip.json +++ b/knip.json @@ -31,6 +31,7 @@ "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", + "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", @@ -188,6 +189,16 @@ "tests/**/*.ts" ] }, + "packages/telemetry/session-telemetry-otel": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/util/brand": { "project": [ "src/**/*.ts" diff --git a/packages/README.md b/packages/README.md index 4e18d7b5f8..93e06631d2 100644 --- a/packages/README.md +++ b/packages/README.md @@ -18,7 +18,7 @@ Packages live at `packages///`; groups are containers, while names r | [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface | | [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface | | [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface | -| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | +| [`compact/`](compact/README.md) | Compaction capability family: abstract seam + basic backend (tool deferred) | Product — stable surface | | [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | | [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface | @@ -32,8 +32,9 @@ Packages live at `packages///`; groups are containers, while names r | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | -| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | +| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, SQLite full-text search | Product — stable surface | | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface | +| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface | | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra | @@ -46,6 +47,6 @@ Groups distinguish product API from support infrastructure. New packages join an The dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI). -**Extension plugins depend on interfaces, never the concrete loop.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities split into interface / implementation / consumer packages; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). +**Extension plugins depend on interfaces, never the concrete loop.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities split into interface/implementation/consumer packages; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts). diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c3e4e1858b..aee1ac9ae8 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -724,6 +724,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'telemetry', + summary: 'The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis\' standard behavior.', + methods: [ + { + signature: 'abstract emit(record: TelemetryRecord): void', + jsDoc: '/**\n * See {@link TelemetryBackend.emit} — the seam declaration is the contract\'s one home.\n * @param record - the logical record to report; owned by the backend after the call.\n */', + }, + { + signature: 'flush?(): void', + jsDoc: '/** See {@link TelemetryBackend.flush}. */', + }, + { + signature: 'abstract shutdown(): Promise', + jsDoc: '/**\n * See {@link TelemetryBackend.shutdown}.\n * @returns resolves when the backend\'s pipeline has quiesced.\n */', + }, + ], + }, { key: 'tokenMeter', summary: 'Replay owner for one service-wide estimator and isolated per-session folds.', @@ -1102,6 +1120,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Emitted when any prompt provider changes. This registry notification is\n * unfiltered because a global change affects every scope.\n * @mode emit\n */', summary: 'Emitted when any prompt provider changes.', }, + { + name: 'telemetry/redact', + mode: 'waterfall', + signature: '\'telemetry/redact\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord', + jsDoc: '/**\n * Redact one outbound record before it reaches the backend. The innermost\n * `next()` applies the seam\'s conservative default rule set\n * (credential-shape scrubbing); listeners stack stricter rules by\n * transforming its return value, and returning without `next()` replaces\n * the default — the exported record is then only as clean as the\n * replacing rule. Dispatched synchronously on the capture hot path inside\n * the coordinator\'s containment: a throwing listener withholds that one\n * record (fail-closed) and never reaches the agent loop. Redaction\n * applies to the exported copy only; the canonical session log is never\n * rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */', + summary: 'Redact one outbound record before it reaches the backend.', + }, { name: 'tools/change', mode: 'emit', @@ -2087,6 +2112,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TaskStatus', declaration: 'export type TaskStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';', }, + { + name: 'TelemetryRecord', + declaration: 'export interface TelemetryRecord {\n channel: \'ledger\' | \'ops\';\n time: number;\n severity: TelemetrySeverity;\n attributes: Record;\n body: unknown;\n}', + }, + { + name: 'TelemetrySeverity', + declaration: 'export type TelemetrySeverity = \'info\' | \'warn\' | \'error\';', + }, { name: 'TerminalCallView', declaration: 'export interface TerminalCallView {\n card: \'terminal\';\n title: string;\n description?: string;\n cwd?: string;\n}', diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md new file mode 100644 index 0000000000..a5869077c2 --- /dev/null +++ b/packages/telemetry/README.md @@ -0,0 +1,8 @@ +# telemetry/ + +Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The design — the boundary axiom (the harness's aspect ends at `emit()`; delivery is the reporting SDK's), the mandatory `telemetry/redact` waterfall, the fixed chunk projection, the handoff cursor, and the operational-record channel — is pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). + +| Package | Role | +|---|---| +| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, handoff cursor, ops signals, and the minimal backend contract (`emit`/`flush?`/`shutdown`). | +| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | The backend a deployment loads: the OTel JS SDK's log pipeline (`LoggerProvider` + `BatchLogRecordProcessor` + OTLP/HTTP exporter), configured verbatim through passthroughs. | diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md new file mode 100644 index 0000000000..26edc62645 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-session-telemetry-otel + +The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. It composes the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and maps each record the seam hands over onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use. + +## Config + +```yaml +- id: telemetry-otel + name: '@deepseek-ai/dsh-session-telemetry-otel' + config: + exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter + url: https://collector.example.com/v1/logs + headers: + authorization: !!js `Bearer ${process.env.OTLP_TOKEN}` + processor: {} # optional; passed verbatim to BatchLogRecordProcessor +``` + +`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load. Everything else is the SDK's option shape, owned and documented by the SDK; batching, retry, queue bounds, and loss policy under sustained failure are its documented behavior, tuned through the `processor` passthrough. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. + +## What leaves the machine + +Records carry the seam's REDACTED copy of `event.data` — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, and the session `cwd` (a local path) — after the seam's `telemetry/redact` waterfall has scrubbed credential-shaped substrings (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. A deployment with stricter requirements stacks `telemetry/redact` listeners or opts out structurally. + +## Field mapping + +Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record staleness (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). + +## Model Experience + +None, as the backend only forwards the seam's redacted records into the OTel SDK pipeline; it never contributes to a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Upstream experimental tree** — `@opentelemetry/sdk-logs` is still published from the upstream experimental tree; SDK API churn lands here and only here — the seam contract does not move. +- **Live-collector smoke is opt-in** — the e2e smoke (`tests/otel.e2e.ts`) self-skips without `$DSH_OTLP_E2E_ENDPOINT`; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape against a mock collector on every run. diff --git a/packages/telemetry/session-telemetry-otel/package.json b/packages/telemetry/session-telemetry-otel/package.json new file mode 100644 index 0000000000..706112d1fe --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/package.json @@ -0,0 +1,52 @@ +{ + "name": "@deepseek-ai/dsh-session-telemetry-otel", + "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.220.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-logs": "^0.220.0", + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-telemetry": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-telemetry": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts new file mode 100644 index 0000000000..4738cdd932 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -0,0 +1,168 @@ +/** + * OpenTelemetry backend for the DeepSeek Harness telemetry seam. + * + * Composes the OTel JS SDK as-is — a `LoggerProvider` with a + * `BatchLogRecordProcessor` and an OTLP/HTTP log exporter — and maps each + * record handed over by the seam onto `logger.emit()`. Per the seam's + * boundary axiom, everything downstream of that call (batching, retry, + * queueing, loss policy) is the SDK's documented behavior, configured + * verbatim through the `exporter`/`processor` passthroughs; this package + * adds no knobs of its own on top of them. + * + * @module @deepseek-ai/dsh-session-telemetry-otel + */ + +import { createRequire } from 'node:module' +import z from 'schemastery' +import type { Context } from 'cordis' +import { Telemetry, TelemetryCoordinator, type TelemetryRecord, type TelemetrySeverity } from '@deepseek-ai/dsh-session-telemetry' +import { APP_IDENTITY } from '@deepseek-ai/dsh-llm' +import { + BatchLogRecordProcessor, + LoggerProvider, + type BatchLogRecordProcessorOptions, +} from '@opentelemetry/sdk-logs' +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' +import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs' +import { resourceFromAttributes } from '@opentelemetry/resources' + +// The package's own manifest is the single source of the instrumentation-scope +// version (same pattern as dsh-llm's attribution identity). +const { version } = createRequire(import.meta.url)('../package.json') as { version: string } + +/** + * Plugin configuration: two verbatim SDK option shapes plus nothing else. + * `exporter.url` is the one field this package validates itself — required, + * no default, must parse as an `http(s)` URL — because a missing endpoint + * must fail at plugin load, not at first export. + */ +export interface Config { + /** Passed verbatim to the SDK's OTLP/HTTP log exporter. */ + exporter?: { + /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ + url?: string + /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */ + headers?: Record + } + /** + * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, + * which this plugin fills); the SDK owns and documents these knobs. + */ + processor?: Omit +} + +/** + * Schemastery validator for {@link Config}; cordis runs it before the plugin + * starts. Shape-level only — the load-bearing `exporter.url` check lives in + * the constructor so its error message names the field. + */ +export const Config: z = z.object({ + exporter: z.object({ + url: z.string(), + headers: z.dict(z.string()), + }), + // Opaque passthrough: the SDK owns this shape and validates its own + // options; re-declaring them here would violate the boundary axiom. + processor: z.any(), +}) + +/** Severity mapping from the seam's three-level vocabulary to OTel severity numbers. */ +const SEVERITY: Record = { + info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' }, + warn: { severityNumber: SeverityNumber.WARN, severityText: 'WARN' }, + error: { severityNumber: SeverityNumber.ERROR, severityText: 'ERROR' }, +} + +/** + * The backend plugin — the only entry a deployment loads. Constructing it + * wires the SDK pipeline, registers the `telemetry` service (duplicate load + * throws, cordis' standard duplicate-service behavior), and composes the + * seam's {@link TelemetryCoordinator}, which installs the capture side onto + * this fiber. + */ +export class TelemetryOtel extends Telemetry { + static inject = ['sessions'] + static Config = Config + + private readonly provider: LoggerProvider + private readonly ledger: Logger + private readonly ops: Logger + + constructor(ctx: Context, config: Config) { + super(ctx) + const url = config.exporter?.url + if (url === undefined || url.length === 0) { + throw new Error('session-telemetry-otel: exporter.url is required (the full OTLP logs endpoint)') + } + let parsed: URL + try { + parsed = new URL(url) + } catch { + // Re-thrown as a config error: the only way here is a malformed url string. + throw new Error(`session-telemetry-otel: exporter.url is not a valid URL: ${JSON.stringify(url)}`) + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`session-telemetry-otel: exporter.url must be http(s), got ${parsed.protocol}`) + } + this.provider = new LoggerProvider({ + resource: resourceFromAttributes({ + 'service.name': APP_IDENTITY.product, + 'service.version': APP_IDENTITY.version, + }), + processors: [ + new BatchLogRecordProcessor({ + ...config.processor, + exporter: new OTLPLogExporter({ + url, + // App identity travels in the Resource (service.name/version); + // the transport-level user-agent is the SDK's own, per the axiom. + // Schemastery fills `headers` with {} before cordis constructs the + // plugin, so the optional type exists for hand-authors only. + headers: config.exporter?.headers as Record, + }), + }), + ], + }) + this.ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version) + this.ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version) + new TelemetryCoordinator(ctx, this) + } + + /** + * Map one seam record onto the SDK logger for its channel — a synchronous + * enqueue into the batch processor's queue. + * @param record - the logical record handed over by the coordinator. + */ + emit(record: TelemetryRecord): void { + const logger = record.channel === 'ops' ? this.ops : this.ledger + logger.emit({ + timestamp: record.time, + observedTimestamp: record.time, + ...SEVERITY[record.severity], + // JSON-serializable by the seam's contract (validated at Session.append), + // which is exactly the AnyValue subset. + body: record.body as AnyValue, + attributes: record.attributes, + }) + } + + /** Forward the turn-boundary hint to the SDK's flush, fire-and-forget. */ + override flush(): void { + // Best-effort hint: the SDK resolves forceFlush even when exports fail + // (failures go to its own diagnostics), and the coordinator stops calling + // this once the fiber is disposed — a rejection would be SDK drift. + /* v8 ignore next -- unreachable guard: forceFlush does not reject while the provider is alive */ + void this.provider.forceFlush().catch(() => {}) + } + + /** + * Delegate disposal to the SDK's shutdown contract: flush the queue and + * quiesce. Awaited (and error-contained) by the coordinator's disposer. + * @returns resolves when the SDK pipeline has quiesced. + */ + shutdown(): Promise { + return this.provider.shutdown() + } +} + +export default TelemetryOtel diff --git a/packages/telemetry/session-telemetry-otel/src/invariant.ts b/packages/telemetry/session-telemetry-otel/src/invariant.ts new file mode 100644 index 0000000000..075e5cc193 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-telemetry-otel`. + * @module @deepseek-ai/dsh-session-telemetry-otel/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-telemetry-otel' + +/** Cordis companion plugin name. */ +export const name = 'session-telemetry-otel-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the backend forwards seam records into the OTel SDK's + * in-process pipeline and appends nothing to any session; its only observable + * effects (batching, export) happen inside the SDK past the seam's boundary + * axiom, out of reach of an independent companion. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..2c72fd6170 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts @@ -0,0 +1,98 @@ +/** + * REAL-composition tier: boot the examples-owned telemetry Loader fixture as + * a subprocess (per testing policy, through the same app/boot path a + * deployment uses), run one mocked-model turn with a real bash round trip, + * and assert against what the mock OTLP collector actually received on the + * wire: ledger mirroring, default redaction, ops markers, and the untouched + * canonical log. + */ + +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { REDACTION_PLACEHOLDER } from '@deepseek-ai/dsh-session-telemetry' + +const driver = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts', + import.meta.url, +)) +const configPath = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +const FIXTURE_SECRET = 'sk-e2efixture1234567890' + +interface OtlpLogRecord { + attributes?: { key: string; value: Record }[] + body?: unknown +} + +interface OtlpCapture { + resourceLogs: { + scopeLogs: { + scope: { name: string } + logRecords: OtlpLogRecord[] + }[] + }[] +} + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +describe('session-telemetry-otel through a real headless cordis.yml', () => { + it('exports redacted ledger records to the collector while the canonical log keeps the secret', async () => { + let captures: OtlpCapture[] = [] + let logContent = '' + const { stderr } = await runLoaderSmoke({ + label: 'session-telemetry-otel loader smoke', + tempDirPrefix: 'telemetry-otel-e2e-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + inspect: async (cwd) => { + captures = JSON.parse(await readFile(join(cwd, 'otlp-captures.json'), 'utf8')) as OtlpCapture[] + const logs = await jsonlFiles(join(cwd, '.sessions')) + expect(logs).toHaveLength(1) + logContent = await readFile(logs[0] as string, 'utf8') + }, + }) + expect(stderr).not.toContain('UNHANDLED') + + const records = captures.flatMap(capture => capture.resourceLogs.flatMap(resource => + resource.scopeLogs.flatMap(scoped => scoped.logRecords.map(record => ({ scope: scoped.scope.name, record }))))) + expect(records.length).toBeGreaterThan(0) + + const eventTypes = records.flatMap(({ record }) => + record.attributes?.flatMap(attribute => + attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string' + ? [attribute.value['stringValue']] + : []) ?? []) + for (const expected of ['turn/start', 'user/message', 'tool/call', 'tool/result', 'assistant/message', 'turn/end']) { + expect(eventTypes, expected).toContain(expected) + } + expect(records.some(({ scope }) => scope.endsWith('/ops'))).toBe(true) + + // Default redaction on the wire: the fixture credential never leaves the + // process, its surrounding prose does, and the placeholder marks the spot. + const wire = JSON.stringify(captures) + expect(wire).not.toContain(FIXTURE_SECRET) + expect(wire).toContain(REDACTION_PLACEHOLDER) + expect(wire).toContain('prove telemetry with key') + + // The canonical session log is never rewritten. + expect(logContent).toContain(FIXTURE_SECRET) + expect(logContent).not.toContain(REDACTION_PLACEHOLDER) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts new file mode 100644 index 0000000000..91c92b3486 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts @@ -0,0 +1,25 @@ +/** + * Keyless-self-skipping smoke: ship one real session's records to a live + * OTLP collector named by $DSH_OTLP_E2E_ENDPOINT and require the SDK's + * shutdown (flush-and-quiesce) to resolve. Skipped without the endpoint so + * secretless CI stays green — a CI accommodation, not a cost signal. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import TelemetryOtel from '../src/index.ts' + +describe.skipIf(!process.env.DSH_OTLP_E2E_ENDPOINT)('telemetry-otel e2e (live collector)', () => { + it('exports a session and quiesces cleanly', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(TelemetryOtel, { + exporter: { url: process.env.DSH_OTLP_E2E_ENDPOINT! }, + }) + const session = ctx.sessions.create(SessionId(`e2e-${Date.now()}`), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await expect(fiber.dispose()).resolves.not.toThrow() + }) +}) diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts new file mode 100644 index 0000000000..88a09a0f3b --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -0,0 +1,166 @@ +/** + * OTel backend unit tier: wire assertions against a scripted `node:http` + * mock collector through the SDK's REAL pipeline (BatchLogRecordProcessor → + * OTLP/HTTP JSON), config fail-loud cases, and the real-Loader-path guard + * for the default-exported Service class. + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { createServer, type Server } from 'node:http' +import { once } from 'node:events' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import TelemetryOtel, { Config } from '../src/index.ts' + +interface Capture { + headers: import('node:http').IncomingHttpHeaders + body: OtlpLogsRequest +} + +/** Just the slice of ExportLogsServiceRequest JSON these assertions touch. */ +interface OtlpLogsRequest { + resourceLogs: { + resource: { attributes: { key: string; value: { stringValue?: string } }[] } + scopeLogs: { + scope: { name: string } + logRecords: { + timeUnixNano: string + severityNumber: number + severityText: string + attributes?: { key: string; value: Record }[] + }[] + }[] + }[] +} + +const servers: Server[] = [] + +afterEach(async () => { + for (const server of servers.splice(0)) { + server.close() + server.closeAllConnections() + } +}) + +async function mockCollector(): Promise<{ url: string; captures: Capture[] }> { + const captures: Capture[] = [] + const server = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', chunk => chunks.push(chunk as Buffer)) + request.on('end', () => { + captures.push({ + headers: request.headers, + body: JSON.parse(Buffer.concat(chunks).toString()) as OtlpLogsRequest, + }) + response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + }) + }) + servers.push(server) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { url: `http://127.0.0.1:${address.port}/v1/logs`, captures } +} + +async function boot(url: string) { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(TelemetryOtel, { + exporter: { url, headers: { authorization: 'Bearer test-token' } }, + }) + return { ctx, fiber } +} + +function allRecords(captures: Capture[]) { + return captures.flatMap(c => c.body.resourceLogs.flatMap(r => r.scopeLogs.flatMap(s => + s.logRecords.map(record => ({ scope: s.scope.name, record }))))) +} + +describe('TelemetryOtel wire', () => { + it('ships session records and the ops shutdown marker through the real SDK pipeline', async () => { + const { url, captures } = await mockCollector() + const { ctx, fiber } = await boot(url) + const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) + await fiber.dispose() + + expect(captures.length).toBeGreaterThan(0) + const first = captures[0]! + const authorization: string | undefined = first.headers.authorization + expect(authorization).toBe('Bearer test-token') + + const resource = first.body.resourceLogs[0]!.resource.attributes + expect(resource).toContainEqual({ key: 'service.name', value: { stringValue: 'deepseek-harness' } }) + + const records = allRecords(captures) + const ledger = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel') + const ops = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops') + + const start = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start')) + expect(start).toBeDefined() + expect(start?.record.severityNumber).toBe(9) + expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.events[0]!.time) * 1_000_000n) + expect(start?.record.attributes).toContainEqual({ key: 'session.cwd', value: { stringValue: '/tmp/w' } }) + + const end = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/end')) + expect(end?.record.severityNumber).toBe(17) + expect(end?.record.severityText).toBe('ERROR') + + expect(ops).toHaveLength(1) + expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) + }) + + it('maps the warn severity and forwards the flush hint to the SDK', async () => { + const { url, captures } = await mockCollector() + const { ctx, fiber } = await boot(url) + const session = ctx.sessions.create(SessionId('warn'), { meta: {} }) + session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' }) + // The turn-boundary hint: safe, non-blocking, and enough to push the batch out. + expect(() => { + ctx.telemetry.flush!() + }).not.toThrow() + await fiber.dispose() + const blocked = allRecords(captures).find(r => + r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'prompt/blocked')) + expect(blocked?.record.severityNumber).toBe(13) + }) +}) + +describe('TelemetryOtel config fails loud', () => { + it.each([ + [{}, /exporter\.url is required/], + [{ exporter: { url: '' } }, /exporter\.url is required/], + [{ exporter: { url: 'not a url' } }, /not a valid URL/], + [{ exporter: { url: 'ftp://collector' } }, /must be http\(s\)/], + ])('rejects %j at plugin load', async (config, message) => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await expect(ctx.plugin(TelemetryOtel, config as Config)).rejects.toThrow(message) + }) +}) + +describe('dsh-session-telemetry-otel real-load-path guard', () => { + it('keeps the Service class with inject/Config through unwrapExports', async () => { + const module = await import('../src/index.ts') + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(module) as typeof TelemetryOtel + expect(unwrapped).toBe(TelemetryOtel) + expect(unwrapped.inject).toEqual(['sessions']) + expect(typeof unwrapped.Config).toBe('function') + }) + + it('boots through the unwrapped class and registers ctx.telemetry', async () => { + const { url } = await mockCollector() + const module = await import('../src/index.ts') + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(module) as Parameters[0] + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(unwrapped, { exporter: { url } }) + expect(ctx.telemetry).toBeInstanceOf(TelemetryOtel) + await fiber.dispose() + }) +}) diff --git a/packages/telemetry/session-telemetry-otel/tsconfig.json b/packages/telemetry/session-telemetry-otel/tsconfig.json new file mode 100644 index 0000000000..9512133cf7 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/session" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../session-telemetry" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md new file mode 100644 index 0000000000..bccc61fd06 --- /dev/null +++ b/packages/telemetry/session-telemetry/README.md @@ -0,0 +1,40 @@ +# @deepseek-ai/dsh-session-telemetry + +The telemetry seam: the CAPTURE side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). + +## The backend contract + +`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path), optional `flush()` (a turn-boundary hint, fire-and-forget), and `shutdown()` (the lifecycle forward: flush-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` in its constructor. + +## Capture points + +The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection — seed events from fork/resume never re-emit on the firehose), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (per adopted session emit its `shutdown` operational record, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). + +## The redact waterfall + +Every record passes the `telemetry/redact` waterfall between projection and `emit()` — nothing reaches a backend unredacted. The innermost `next()` applies the built-in conservative rule set (`applyDefaultRedaction`: credential shapes — API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo — replaced with `[REDACTED]` in body strings and string attribute values). Listeners stack stricter rules by transforming `next()`'s return value; returning without `next()` replaces the default rule set, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. The built-in pattern list is a security invariant, deliberately not configurable from cordis.yml. Redaction applies to the exported copy only; the canonical session log is never rewritten. + +## The handoff cursor + +A module-scope `WeakMap` marks the highest seq HANDED OFF (not delivered) per session, advanced at emit time. It survives reloads that do not re-evaluate this module — config re-applies and backend source reloads, which is where iteration happens; that asymmetry is why the cursor lives in the seam. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a full re-hand, absorbed by receiver-side dedupe on `(session.id, event.seq)`. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error. + +## The fixed chunk projection + +Only the first `assistant/chunk` of each `(turn, step)` ships; the rest are dropped at capture and never advance the cursor. That one chunk is the stream-started signal: `step/start` + first-chunk presence + `assistant/message` presence + the `turn/end` reason distinguish "the request never started" from "the stream died midway" without chunk volume, and time-to-first-token stays computable. Chunk elision makes `seq` gaps routine on the wire — a gap is never a loss signal. Every other event type, including ones merged by plugins this package never heard of, passes through whole. + +## The logical record + +`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError`, `turn/end` error reasons, `compact/end` errors; WARN for `prompt/blocked`; INFO otherwise), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `telemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`. + +## Model Experience + +None, as the seam only observes the session stream and hands redacted copies to a reporting backend; it never contributes to a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Best-effort delivery** — the cursor marks handed-off, not delivered; a session torn down inside a reload window cannot be re-adopted; whatever sits in a backend queue at crash time is lost. A durable outbox (spool, per-sink cursors, at-least-once) is deferred until a deployment states a crash-loss requirement — see [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). +- **Redaction is shape-based** — the default rules catch known credential shapes, not every secret; a deployment with stricter needs stacks `telemetry/redact` listeners, and exported data is only as clean as the mounted rules. diff --git a/packages/telemetry/session-telemetry/package.json b/packages/telemetry/session-telemetry/package.json new file mode 100644 index 0000000000..71646c130e --- /dev/null +++ b/packages/telemetry/session-telemetry/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-session-telemetry", + "description": "Telemetry seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts new file mode 100644 index 0000000000..08fbe498b1 --- /dev/null +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -0,0 +1,244 @@ +/** + * Capture coordinator: the seam's upstream half. Subscribes to the session + * firehose plus the one live-bus relay (`agent/error`), applies the fixed + * chunk projection, builds logical records, runs each through the + * `telemetry/redact` waterfall, and hands the redacted copy to the backend — + * synchronously, with every handler self-contained so a failing backend can + * never starve other subscribers (cordis `emit` is stop-on-throw) or touch + * the agent loop. Composed by a backend in its constructor. + * + * @module @deepseek-ai/dsh-session-telemetry/coordinator + */ + +import type { Context } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { TelemetryBackend, TelemetryRecord, TelemetrySeverity } from './index.ts' +import { applyDefaultRedaction } from './redact.ts' + +/** + * The handoff cursor: per session, the highest `seq` handed to a backend. + * Deliberately MODULE-scope ambient state — a narrow, documented exception + * to the registrations-are-effects discipline: cordis has no HMR + * state-handover API, and keying by the `Session` object (which belongs to + * the session store and outlives any telemetry fiber) is the only in-process + * lifetime that lets a re-adopting fiber resume instead of re-handing + * history. Entries die with their sessions; a missing entry safely means + * "re-hand everything". Advanced only at emit time — the cursor marks + * handed-off, not delivered. + */ +const handoffCursor = new WeakMap() + +/** + * Install the telemetry capture side onto a context for one backend. + * + * Registers the persistence-coordinator listener set plus the `agent/error` + * relay, all through `ctx.effect()`/`ctx.on()` on the composing fiber, and + * sweeps already-live sessions (a hot reload does not replay + * `session/created`). Disposal emits each adopted session's `shutdown` + * operational record and then awaits the backend's `shutdown()`; a failure + * there warns instead of throwing — best-effort reporting must not fail + * application teardown. + */ +export class TelemetryCoordinator { + /** Sessions adopted by THIS fiber, for dispose-time `shutdown` records and double-adoption protection. */ + private readonly adopted = new Set() + /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */ + private readonly chunkSeen = new WeakMap>() + + /** + * @param ctx - the composing backend's context; listeners bind to its fiber. + * @param backend - the backend receiving records; owned elsewhere, never disposed here beyond `shutdown()` forwarding. + */ + constructor( + private readonly ctx: Context, + private readonly backend: TelemetryBackend, + ) { + ctx.on('session/created', (session) => { + this.adopt(session) + }) + ctx.on('session/event', (session, event) => { + this.contain(() => { + this.capture(session, event) + }) + }) + // Parallel listeners are awaited by the loop at turn end; returning void + // (not the SDK's flush promise) is the turn-latency contract. + ctx.on('session/flush', (session) => { + this.contain(() => { + this.hintFlush(session) + }) + }) + ctx.on('agent/error', (agent, turn, step, error) => { + this.contain(() => { + this.relayAgentError(agent, turn, step, error) + }) + }) + ctx.effect(() => async () => { + for (const session of this.adopted) { + this.contain(() => { + this.handOff(shutdownRecord(session)) + }) + } + try { + await this.backend.shutdown() + } catch (error) { + this.ctx.logger.warn(`telemetry: backend shutdown failed: ${String(error)}`) + } + }, 'telemetry capture') + for (const session of ctx.sessions.list()) { + this.adopt(session) + } + } + + /** + * Adopt a session: replay its log THROUGH the projection from the handoff + * cursor (or from the start when no cursor survived), then rely on the + * firehose for everything after. Events at or below the cursor still feed + * the projection state (first-chunk tracking) without being re-handed, so + * a resumed fiber drops mid-step chunk continuations exactly like the + * fiber that saw the step begin. + * @param session - the live session to adopt; a second adoption is a no-op. + */ + private adopt(session: Session): void { + this.contain(() => { + if (this.adopted.has(session)) return + this.adopted.add(session) + const cursor = handoffCursor.get(session) ?? -1 + for (const event of session.events) { + if (event.seq <= cursor) this.track(session, event) + else this.capture(session, event) + } + }) + } + + /** Feed the chunk projection without handing off — the ≤cursor half of re-adoption. */ + private track(session: Session, event: SessionEvent): void { + if (event.type === 'assistant/chunk') { + this.seen(session).add(`${event.data.turn}:${event.data.step}`) + } + } + + /** Project one event and hand it to the backend, advancing the cursor on handoff. */ + private capture(session: Session, event: SessionEvent): void { + if (event.type === 'assistant/chunk') { + const key = `${event.data.turn}:${event.data.step}` + const seen = this.seen(session) + // Fixed chunk projection: only the first chunk of each (turn, step) + // ships — the stream-started signal; content is byte-complete in the + // step's assembled assistant/message. Dropped chunks do not advance + // the cursor, so re-adoption re-drops them deterministically. + if (seen.has(key)) return + seen.add(key) + } + this.handOff({ + channel: 'ledger', + time: event.time, + severity: severityOf(event), + attributes: identityOf(session, event), + // The live event object is mutable and the backend serializes later; + // append-time validation guarantees this clone cannot throw. + body: structuredClone(event.data), + }) + handoffCursor.set(session, event.seq) + } + + /** + * Run the `telemetry/redact` waterfall over one record and hand the result + * to the backend. The innermost `next` applies the seam's conservative + * default rules, so an unconfigured deployment still never exports raw + * credential shapes; callers run inside {@link contain}, so a throwing + * rule withholds the record instead of reaching the loop (fail-closed). + */ + private handOff(record: TelemetryRecord): void { + this.backend.emit(this.ctx.waterfall('telemetry/redact', record, () => applyDefaultRedaction(record))) + } + + /** Forward the turn-end boundary to the backend's optional flush hint. */ + private hintFlush(session: Session): void { + if (this.adopted.has(session)) this.backend.flush?.() + } + + /** Relay one `agent/error` bus emission as an `agent-error` operational record. */ + private relayAgentError(agent: Agent, turn: number, step: number, error: Error): void { + this.handOff({ + channel: 'ops', + time: Date.now(), + severity: 'error', + attributes: { + 'telemetry.op': 'agent-error', + 'session.id': String(agent.session.id), + 'agent.id': agent.id, + 'error.name': error.name, + turn, + step, + }, + body: { name: error.name, message: error.message }, + }) + } + + /** Lazily create the per-session first-chunk tracking set. */ + private seen(session: Session): Set { + let set = this.chunkSeen.get(session) + if (!set) this.chunkSeen.set(session, set = new Set()) + return set + } + + /** + * Run one capture-side step with its exception contained: cordis `emit` + * is stop-on-throw, so a throwing listener would starve every subscriber + * registered after this plugin — nothing from the backend may escape. + */ + private contain(step: () => void): void { + try { + step() + } catch (error) { + this.ctx.logger.warn(`telemetry: capture step failed: ${String(error)}`) + } + } +} + +/** Build the per-session clean-exit marker emitted at dispose, before the backend's `shutdown()`. */ +function shutdownRecord(session: Session): TelemetryRecord { + return { + channel: 'ops', + time: Date.now(), + severity: 'info', + attributes: { 'telemetry.op': 'shutdown', 'session.id': String(session.id) }, + body: { op: 'shutdown' }, + } +} + +/** Map an event's own outcome flag to the pre-baked alerting severity. */ +function severityOf(event: SessionEvent): TelemetrySeverity { + switch (event.type) { + case 'tool/result': + return event.data.isError ? 'error' : 'info' + case 'turn/end': + return event.data.reason.kind === 'error' ? 'error' : 'info' + case 'prompt/blocked': + return 'warn' + default: { + // Merge-extensible fall-through (no assertNever): types this seam does + // not depend on still get their RFC-pinned severity via a widened + // probe — `compact/end` is declared by dsh-compact, which the seam + // deliberately does not import. + const type: string = event.type + if (type === 'compact/end' && (event.data as { error?: unknown }).error !== undefined) return 'error' + return 'info' + } + } +} + +/** Build the minimal identity attributes: envelope plus self-contained header facts. */ +function identityOf(session: Session, event: SessionEvent): Record { + const attributes: Record = { + 'session.id': String(session.id), + 'event.type': event.type, + 'event.seq': event.seq, + } + const { cwd, parentSession } = session.header + if (cwd !== undefined) attributes['session.cwd'] = cwd + if (parentSession !== undefined) attributes['session.parent_id'] = String(parentSession) + return attributes +} diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts new file mode 100644 index 0000000000..93e7e7b0f9 --- /dev/null +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -0,0 +1,145 @@ +/** + * Telemetry seam for the DeepSeek Harness. + * + * The seam owns the CAPTURE side of session-event reporting — which records + * exist (the chunk projection), what they carry (the logical record), when + * they are handed over (adoption, the per-append firehose, lifecycle + * forwarding), and the HMR handoff cursor. Everything downstream of + * {@link Telemetry.emit} — batching, retry, queueing, loss policy — is the + * reporting SDK's territory and is deliberately not modelled here. The + * design and its trade-offs are pinned in + * .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md. + * + * @module @deepseek-ai/dsh-session-telemetry + */ + +import { Context, Service } from 'cordis' + +declare module 'cordis' { + interface Context { + telemetry: Telemetry + } + + interface Events { + /** + * Redact one outbound record before it reaches the backend. The innermost + * `next()` applies the seam's conservative default rule set + * (credential-shape scrubbing); listeners stack stricter rules by + * transforming its return value, and returning without `next()` replaces + * the default — the exported record is then only as clean as the + * replacing rule. Dispatched synchronously on the capture hot path inside + * the coordinator's containment: a throwing listener withholds that one + * record (fail-closed) and never reaches the agent loop. Redaction + * applies to the exported copy only; the canonical session log is never + * rewritten. + * @param record - the candidate record, already the coordinator's own deep + * copy; listeners return a (possibly new) record and must not mutate it. + * @mode waterfall + */ + 'telemetry/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord + } +} + +/** + * Severity of a telemetry record, pre-mapped at capture so a receiver can + * alert with zero configuration: `error` for events whose own outcome flag + * says so (`tool/result.isError`, `turn/end` error reasons, `compact/end` + * errors) and for `agent-error` operational records, `warn` for + * `prompt/blocked`, `info` for everything else. + */ +export type TelemetrySeverity = 'info' | 'warn' | 'error' + +/** + * One logical record handed to a backend — the seam's whole outbound + * vocabulary. Ledger records mirror session-log events one-to-one; + * operational records (`channel: 'ops'`) carry the two signals with no log + * home (`agent-error`, `shutdown`) and deliberately omit `event.seq`-style + * identity so they can never be mistaken for ledger rows. + */ +export interface TelemetryRecord { + /** Ledger (session-log mirror) or ops (operational signal) channel; backends keep the two under separate instrumentation scopes. */ + channel: 'ledger' | 'ops' + /** Unix epoch milliseconds — the source event's append time for ledger records, the emission time for ops records. */ + time: number + /** Pre-mapped alerting severity; see {@link TelemetrySeverity}. */ + severity: TelemetrySeverity + /** + * Identity attributes, deliberately minimal: ledger records carry + * `session.id`, `event.type`, `event.seq`, plus `session.cwd` / + * `session.parent_id` when the header has them; ops records carry + * `telemetry.op`, `session.id`, and (for `agent-error`) `agent.id`, + * `turn`, `step`, `error.name`. Anything recoverable from the body is + * intentionally NOT duplicated here. + */ + attributes: Record + /** + * The complete payload: a deep copy of the session event's `data` for + * ledger records (JSON-serializable by `Session.append`'s own + * validation), or the op payload for ops records. Never mutated after + * handoff. + */ + body: unknown +} + +/** + * The backend contract the coordinator hands records to — the minimum any + * reporting SDK satisfies with zero bending. {@link Telemetry} is its + * service-registered form; tests compose the coordinator with a bare + * implementation of this interface. + */ +export interface TelemetryBackend { + /** + * Hand one record to the backend's pipeline. MUST be a non-blocking + * enqueue — the coordinator calls this synchronously from the + * `session/event` hot path, so anything slower than a queue push would tax + * the agent loop. Errors thrown here are contained by the coordinator and + * logged; they never reach the loop. + * @param record - the logical record to report; owned by the backend after the call. + */ + emit(record: TelemetryRecord): void + /** + * Optional hint that a natural boundary (turn end) passed — a backend may + * forward it to its SDK's flush so records land at turn boundaries. Called + * fire-and-forget; implementations must not block and must not throw + * meaningfully (the coordinator contains exceptions). + */ + flush?(): void + /** + * Forward the fiber's disposal to the SDK: flush whatever is queued and + * reach quiescence, per the SDK's own shutdown contract. Awaited by the + * coordinator's dispose; a rejection is logged as a warning and never + * fails application teardown. + * @returns resolves when the backend's pipeline has quiesced. + */ + shutdown(): Promise +} + +/** + * The backend contract in its loadable form: one implementation per context — + * the cordis `Service` registration under the `telemetry` key throws on a + * duplicate, cordis' standard behavior. A backend composes a + * {@link TelemetryCoordinator} in its constructor to install the capture side. + */ +export abstract class Telemetry extends Service implements TelemetryBackend { + constructor(ctx: Context) { + super(ctx, 'telemetry') + } + + /** + * See {@link TelemetryBackend.emit} — the seam declaration is the contract's one home. + * @param record - the logical record to report; owned by the backend after the call. + */ + abstract emit(record: TelemetryRecord): void + + /** See {@link TelemetryBackend.flush}. */ + flush?(): void + + /** + * See {@link TelemetryBackend.shutdown}. + * @returns resolves when the backend's pipeline has quiesced. + */ + abstract shutdown(): Promise +} + +export { TelemetryCoordinator } from './coordinator.ts' +export { applyDefaultRedaction, REDACTION_PLACEHOLDER } from './redact.ts' diff --git a/packages/telemetry/session-telemetry/src/invariant.ts b/packages/telemetry/session-telemetry/src/invariant.ts new file mode 100644 index 0000000000..ffc55b0107 --- /dev/null +++ b/packages/telemetry/session-telemetry/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-telemetry`. + * @module @deepseek-ai/dsh-session-telemetry/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-telemetry' + +/** Cordis companion plugin name. */ +export const name = 'session-telemetry-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the seam's whole output is the backend handoff — a + * synchronous `emit()` call outside every authoritative event stream — and its + * capture side never appends session events, so no event/data relation exists + * for an independent companion to observe. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/telemetry/session-telemetry/src/redact.ts b/packages/telemetry/session-telemetry/src/redact.ts new file mode 100644 index 0000000000..d25452581a --- /dev/null +++ b/packages/telemetry/session-telemetry/src/redact.ts @@ -0,0 +1,77 @@ +/** + * Conservative default redaction for outbound telemetry records. + * + * Session-event bodies carry file contents and command output that may embed + * credentials; nothing may cross the seam to a backend unredacted. This module + * is the innermost rule set of the `telemetry/redact` waterfall — always + * applied unless an outer listener deliberately replaces the whole chain. It + * scrubs credential-SHAPED substrings from every string in the record body, + * leaving structure (keys, nesting, surrounding prose) intact. The pattern + * list is a security invariant, deliberately not configurable; deployments + * add stricter rules by stacking `telemetry/redact` listeners. + * + * @module @deepseek-ai/dsh-session-telemetry/redact + */ + +import type { TelemetryRecord } from './index.ts' + +/** Replacement text substituted for each detected credential-shaped span. */ +export const REDACTION_PLACEHOLDER = '[REDACTED]' + +/** + * Well-known credential shapes. A match anywhere inside a body string is + * replaced; low-signal values (package names, versions, git SHAs, plain URLs) + * deliberately stay untouched — they are the observability signal. + */ +const SECRET_PATTERNS: readonly RegExp[] = [ + /sk-(?:ant-)?[A-Za-z0-9_-]{10,}/g, // DeepSeek / OpenAI / Anthropic API keys + /gh[pousr]_[A-Za-z0-9]{16,}/g, // GitHub personal/oauth/server/refresh tokens + /github_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT + /xox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens + /AKIA[0-9A-Z]{16}/g, // AWS access key id + /AIza[0-9A-Za-z_-]{35}/g, // Google API key + /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, // JWT + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, // PEM blocks + /\b(?[a-z][a-z0-9+.-]*):\/\/[^/\s:@]+:[^/\s:@]+@/g, // URL userinfo credentials +] + +/** Replace every known credential shape inside one string. */ +function scrub(text: string): string { + let out = text + for (const pattern of SECRET_PATTERNS) { + out = out.replace(pattern, REDACTION_PLACEHOLDER) + } + return out +} + +/** + * Deep-scrub every string inside a lossless-JSON value, preserving structure. + * The record body is the coordinator's own `structuredClone` — mutation-free + * rebuilding keeps the exported copy independent of the canonical log either way. + */ +function scrubValue(value: unknown): unknown { + if (typeof value === 'string') return scrub(value) + if (Array.isArray(value)) return value.map(scrubValue) + if (value !== null && typeof value === 'object') { + const out: Record = {} + for (const [key, entry] of Object.entries(value)) out[key] = scrubValue(entry) + return out + } + return value +} + +/** + * Apply the conservative default rule set to one record — the innermost + * `next` of the `telemetry/redact` waterfall. Attribute VALUES are scrubbed + * alongside the body (identity attributes are seam-built and boring, but + * `session.cwd` is caller-supplied); attribute keys are seam-owned constants. + * @param record - the candidate record; not mutated. + * @returns a redacted copy safe to hand to a backend. + */ +export function applyDefaultRedaction(record: TelemetryRecord): TelemetryRecord { + const attributes: Record = {} + for (const [key, value] of Object.entries(record.attributes)) { + attributes[key] = typeof value === 'string' ? scrub(value) : value + } + return { ...record, attributes, body: scrubValue(record.body) } +} diff --git a/packages/telemetry/session-telemetry/tests/redact.spec.ts b/packages/telemetry/session-telemetry/tests/redact.spec.ts new file mode 100644 index 0000000000..cd23af6da2 --- /dev/null +++ b/packages/telemetry/session-telemetry/tests/redact.spec.ts @@ -0,0 +1,157 @@ +/** + * Default redaction rules and the `telemetry/redact` waterfall contract: + * credential shapes scrubbed from bodies and attribute values, structure + * preserved, canonical log untouched, listener stacking/replacement, and the + * fail-closed containment of a throwing rule. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { + applyDefaultRedaction, + REDACTION_PLACEHOLDER, + TelemetryCoordinator, + type TelemetryBackend, + type TelemetryRecord, +} from '../src/index.ts' + +const SECRETS = { + deepseek: 'sk-abcdef1234567890abcdef', + anthropic: 'sk-ant-abcdef1234567890', + githubPat: 'ghp_ABCDEFGHIJKLMNOPqrstuv12345678', + finePat: 'github_pat_ABCDEFGHIJKLMNOPQRSTuvwx', + slack: 'xoxb-1234567890-abcdefghij', + aws: 'AKIAIOSFODNN7EXAMPLE', + google: 'AIzaSyA-1234567890abcdefghijklmnopqrstu', + jwt: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpM', + pem: '-----BEGIN RSA PRIVATE KEY-----\nMIIEow\n-----END RSA PRIVATE KEY-----', + urlCreds: 'https://user:hunter2@internal.example.com/repo.git', +} as const + +function record(body: unknown, attributes: Record = {}): TelemetryRecord { + return { channel: 'ledger', time: 1, severity: 'info', attributes, body } +} + +describe('applyDefaultRedaction', () => { + it('scrubs every known credential shape while preserving surrounding text', () => { + for (const secret of Object.values(SECRETS)) { + const out = applyDefaultRedaction(record(`before ${secret} after`)) + expect(out.body, secret).not.toContain(secret.includes('\n') ? 'MIIEow' : secret) + expect(out.body).toContain('before ') + expect(out.body).toContain(' after') + expect(out.body).toContain(REDACTION_PLACEHOLDER) + } + }) + + it('scrubs URL userinfo credentials but leaves plain URLs alone', () => { + const out = applyDefaultRedaction(record(`${SECRETS.urlCreds} and https://example.com/path`)) + expect(out.body).not.toContain('hunter2') + expect(out.body).toContain('https://example.com/path') + }) + + it('recurses through arrays and objects, preserving structure and non-strings', () => { + const out = applyDefaultRedaction(record({ + list: [`key=${SECRETS.deepseek}`, 7, null, true], + nested: { text: SECRETS.githubPat, count: 3 }, + })) + expect(out.body).toEqual({ + list: [`key=${REDACTION_PLACEHOLDER}`, 7, null, true], + nested: { text: REDACTION_PLACEHOLDER, count: 3 }, + }) + }) + + it('leaves low-signal values untouched', () => { + const clean = { + pkg: '@deepseek-ai/dsh-session-telemetry@0.0.1', + sha: '342a4c3a9d3adf13cf4ad33b9f8d6e79170be5e2', + prose: 'ordinary sentence with kebab-case-identifier', + } + expect(applyDefaultRedaction(record(clean)).body).toEqual(clean) + }) + + it('scrubs string attribute values and keeps numeric ones', () => { + const out = applyDefaultRedaction(record(null, { + 'session.cwd': `/home/${SECRETS.aws}/proj`, + 'event.seq': 4, + })) + expect(out.attributes['session.cwd']).toBe(`/home/${REDACTION_PLACEHOLDER}/proj`) + expect(out.attributes['event.seq']).toBe(4) + }) + + it('never mutates its input', () => { + const input = record({ text: SECRETS.slack }, { 'session.cwd': SECRETS.aws }) + applyDefaultRedaction(input) + expect((input.body as { text: string }).text).toBe(SECRETS.slack) + expect(input.attributes['session.cwd']).toBe(SECRETS.aws) + }) +}) + +class CollectingBackend implements TelemetryBackend { + records: TelemetryRecord[] = [] + emit(record: TelemetryRecord): void { + this.records.push(record) + } + async shutdown(): Promise {} +} + +async function setup() { + const backend = new CollectingBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + return { ctx, backend } +} + +describe('telemetry/redact waterfall', () => { + it('applies the default rules when no listener is registered', async () => { + const { ctx, backend } = await setup() + const session = ctx.sessions.create(SessionId('w')) + session.append('user/message', { content: [{ type: 'text', text: `key ${SECRETS.deepseek}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const body = backend.records[0]!.body as { content: { text: string }[] } + expect(body.content[0]!.text).toBe(`key ${REDACTION_PLACEHOLDER}`) + }) + + it('keeps the canonical log unredacted', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create(SessionId('log')) + session.append('user/message', { content: [{ type: 'text', text: SECRETS.githubPat }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const logged = session.events[0]!.data as { content: { text: string }[] } + expect(logged.content[0]!.text).toBe(SECRETS.githubPat) + }) + + it('lets a listener stack a stricter rule on top of the defaults', async () => { + const { ctx, backend } = await setup() + ctx.on('telemetry/redact', (_record, next) => { + const defaulted = next() + return { ...defaulted, body: { shapeOnly: true } } + }) + const session = ctx.sessions.create(SessionId('stack')) + session.append('user/message', { content: [{ type: 'text', text: 'anything' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records[0]!.body).toEqual({ shapeOnly: true }) + }) + + it('a listener that skips next() replaces the default rules', async () => { + const { ctx, backend } = await setup() + ctx.on('telemetry/redact', record => record) + const session = ctx.sessions.create(SessionId('veto')) + session.append('user/message', { content: [{ type: 'text', text: SECRETS.slack }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const body = backend.records[0]!.body as { content: { text: string }[] } + expect(body.content[0]!.text).toBe(SECRETS.slack) + }) + + it('a throwing rule withholds the record fail-closed without disturbing the log', async () => { + const { ctx, backend } = await setup() + ctx.on('telemetry/redact', () => { + throw new Error('rule exploded') + }) + const session = ctx.sessions.create(SessionId('closed')) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records).toHaveLength(0) + expect(session.events).toHaveLength(1) + }) +}) diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts new file mode 100644 index 0000000000..9f694749ef --- /dev/null +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -0,0 +1,313 @@ +/** + * Coordinator semantics against a bare fake backend — the RFC's named unit + * tier for the seam: adoption (fresh, seeded, re-adoption via the handoff + * cursor), the fixed chunk projection, deep-copy isolation, turn-latency and + * dispose-ordering pins, failure containment, and the `agent/error` relay. + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { TelemetryCoordinator, type TelemetryBackend, type TelemetryRecord } from '../src/index.ts' + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * Test-only merged event proving unknown types flow through unchanged. + * @mode emit + * @param payload - opaque test payload + */ + 'telemetry-test/opaque': { payload: { nested: string[] } } + /** + * Test-only stand-in for dsh-compact's merge, exercising the widened severity probe. + * @mode emit + * @param error - failure text when the compaction failed + */ + 'compact/end': { turn: number; error?: string } + } +} + +class FakeBackend implements TelemetryBackend { + records: TelemetryRecord[] = [] + calls: string[] = [] + emitError: Error | undefined + shutdownError: Error | undefined + shutdownResolved = false + + emit(record: TelemetryRecord): void { + if (this.emitError) throw this.emitError + this.records.push(record) + this.calls.push(`emit:${String(record.attributes['event.seq'] ?? record.attributes['telemetry.op'])}`) + } + + flush = vi.fn() + + async shutdown(): Promise { + this.calls.push('shutdown') + await new Promise(resolve => setTimeout(resolve, 5)) + if (this.shutdownError) throw this.shutdownError + this.shutdownResolved = true + } + + ledger(): TelemetryRecord[] { + return this.records.filter(r => r.channel === 'ledger') + } +} + +async function setup(backend: FakeBackend = new FakeBackend()) { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + return { ctx, backend, fiber } +} + +function liveSession(ctx: Context, id = `s-${Math.random().toString(36).slice(2)}`): Session { + return ctx.sessions.create(SessionId(id), { meta: {} }) +} + +function appendTurn(session: Session): void { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) +} + +describe('TelemetryCoordinator capture', () => { + it('hands every appended event over with envelope identity and cloned body', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx, 'cap') + appendTurn(session) + + const start = backend.ledger()[0]! + const message = backend.ledger()[1]! + expect(start.attributes).toMatchObject({ 'session.id': 'cap', 'event.type': 'turn/start', 'event.seq': 0 }) + expect(start.time).toBe(session.events[0]!.time) + expect(start.severity).toBe('info') + expect(message.attributes['event.seq']).toBe(1) + // Deep-copy isolation: mutating the handed-off body never reaches the log. + ;(message.body as { content: { text: string }[] }).content[0]!.text = 'tampered' + const logged = session.events[1] as SessionEvent<'user/message'> + expect(logged.data.content[0]).toMatchObject({ text: 'hello' }) + }) + + it('stamps header facts on every record when present', async () => { + const { ctx, backend } = await setup() + const parent = SessionId('parent') + const session = ctx.sessions.create(SessionId('child'), { meta: { cwd: '/tmp/proj', parentSession: parent } }) + appendTurn(session) + for (const record of backend.ledger()) { + expect(record.attributes['session.cwd']).toBe('/tmp/proj') + expect(record.attributes['session.parent_id']).toBe('parent') + } + }) + + it('maps outcome flags to severity, including the widened merge-extensible probe', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('tool/result', { turn: 1, step: 1, callId: 'c1' as never, content: [], isError: true }, { surfaceOp: 'append' }) + session.append('tool/result', { turn: 1, step: 1, callId: 'c2' as never, content: [], isError: false }, { surfaceOp: 'append' }) + session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' }) + session.append('compact/end', { turn: 1, error: 'summarizer died' }) + session.append('compact/end', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) + const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity]) + expect(severities).toEqual([ + ['turn/start', 'info'], + ['tool/result', 'error'], + ['tool/result', 'info'], + ['prompt/blocked', 'warn'], + ['compact/end', 'error'], + ['compact/end', 'info'], + ['turn/end', 'error'], + ]) + }) + + it('passes unknown merged event types through unchanged', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx) + session.append('telemetry-test/opaque', { payload: { nested: ['a', 'b'] } }) + const record = backend.ledger()[0]! + expect(record.attributes['event.type']).toBe('telemetry-test/opaque') + expect(record.severity).toBe('info') + expect(record.body).toEqual({ payload: { nested: ['a', 'b'] } }) + }) + + it('ships only the first chunk of each (turn, step), per session', async () => { + const { ctx, backend } = await setup() + const a = liveSession(ctx, 'a') + const b = liveSession(ctx, 'b') + const chunk = (s: Session, turn: number, step: number, text: string) => + s.append('assistant/chunk', { turn, step, chunk: { type: 'text-delta', index: 0, text } }) + chunk(a, 1, 1, 'a11-first') + chunk(a, 1, 1, 'a11-second') + chunk(a, 1, 2, 'a12-first') + chunk(b, 1, 1, 'b11-first') + chunk(b, 1, 1, 'b11-second') + const shipped = backend.ledger().map(r => [r.attributes['session.id'], (r.body as { chunk: { text: string } }).chunk.text]) + expect(shipped).toEqual([ + ['a', 'a11-first'], + ['a', 'a12-first'], + ['b', 'b11-first'], + ]) + }) +}) + +describe('TelemetryCoordinator adoption', () => { + it('reads seeded events back at adoption (fork/resume seeds never re-emit)', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const parent = liveSession(ctx, 'seed-parent') + appendTurn(parent) + ctx.sessions.create(SessionId('seeded'), { seed: [...parent.events], meta: {} }) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']]) + expect(seqs).toEqual(expect.arrayContaining([ + ['seed-parent', 0], ['seed-parent', 1], + ['seeded', 0], ['seeded', 1], + ])) + }) + + it('adopts exactly once when created fires after the sweep', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + // The enter/announce window: prepare+enter puts the session in the store + // (visible to the constructor sweep) before `session/created` fires, so a + // coordinator loaded inside that window sees the session twice — sweep + // first, created second. The second adoption must be a no-op. + const session = ctx.sessions.prepare(SessionId('overlap')) + appendTurn(session) + ctx.sessions.enter(session) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + expect(backend.ledger()).toHaveLength(2) + ctx.sessions.announce(session) + expect(backend.ledger()).toHaveLength(2) + }) + + it('resumes from the handoff cursor across a reload, re-dropping mid-step chunks', async () => { + const backend = new FakeBackend() + const { ctx, fiber } = await setup(backend) + const session = liveSession(ctx, 'hmr') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }) + expect(backend.ledger()).toHaveLength(2) + + await fiber.dispose() + // The reload window: appends while no telemetry listener is registered. + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'mid-step continuation' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const second = new FakeBackend() + await ctx.plugin({ + name: 'fake-telemetry-2', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, second), + }) + // Only the window events past the cursor are re-handed, and the mid-step + // continuation is re-dropped because ≤cursor events rebuilt the projection. + expect(second.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end']) + }) + + it('re-hands the full log when no cursor survived (fresh session object)', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = liveSession(ctx, 'fresh') + appendTurn(session) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + expect(backend.ledger().map(r => r.attributes['event.seq'])).toEqual([0, 1]) + }) +}) + +describe('TelemetryCoordinator lifecycle and containment', () => { + it('forwards session/flush as a hint without awaiting backend work', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx) + let settled = false + backend.flush.mockImplementation(() => { + // The backend may kick off arbitrary async work; the loop's parallel must not wait for it. + void new Promise(resolve => setTimeout(resolve, 50)).then(() => { settled = true }) + }) + await ctx.parallel('session/flush', session) + expect(backend.flush).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + }) + + it('ignores flush hints for sessions it never adopted', async () => { + const { ctx, backend } = await setup() + const stranger = ctx.sessions.prepare(SessionId('stranger'), { meta: {} }) + await ctx.parallel('session/flush', stranger) + expect(backend.flush).not.toHaveBeenCalled() + }) + + it('emits each adopted session’s shutdown record before awaiting backend shutdown', async () => { + const { ctx, backend, fiber } = await setup() + liveSession(ctx, 's1') + liveSession(ctx, 's2') + await fiber.dispose() + expect(backend.calls).toEqual(['emit:shutdown', 'emit:shutdown', 'shutdown']) + expect(backend.shutdownResolved).toBe(true) + const ops = backend.records.filter(r => r.channel === 'ops') + expect(ops.map(r => r.attributes['session.id']).sort()).toEqual(['s1', 's2']) + expect(ops.every(r => r.attributes['telemetry.op'] === 'shutdown' && r.severity === 'info')).toBe(true) + expect(ops.every(r => !('event.seq' in r.attributes) && !('event.type' in r.attributes))).toBe(true) + }) + + it('warns instead of throwing when backend shutdown fails', async () => { + const backend = new FakeBackend() + backend.shutdownError = new Error('exporter unreachable') + const { ctx, fiber } = await setup(backend) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + liveSession(ctx) + await expect(fiber.dispose()).resolves.not.toThrow() + expect(warn.mock.calls.some(args => String(args[0]).includes('shutdown failed'))).toBe(true) + }) + + it('contains emit failures: the append succeeds and capture heals', async () => { + const { ctx, backend } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const session = liveSession(ctx) + backend.emitError = new Error('backend broke') + expect(() => session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow() + expect(warn).toHaveBeenCalled() + backend.emitError = undefined + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(backend.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end']) + }) + + it('relays agent/error as an ops record with identity and structured name', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx, 'erring') + // Only the members the relay reads; the full Agent surface is irrelevant here. + const agent = { id: 'agent-1', session } as Agent + ctx.emit('agent/error', agent, 3, 2, new TypeError('adapter exploded')) + const record = backend.records.find(r => r.channel === 'ops')! + expect(record.severity).toBe('error') + expect(record.attributes).toMatchObject({ + 'telemetry.op': 'agent-error', + 'session.id': 'erring', + 'agent.id': 'agent-1', + 'error.name': 'TypeError', + turn: 3, + step: 2, + }) + expect(record.body).toEqual({ name: 'TypeError', message: 'adapter exploded' }) + }) +}) diff --git a/packages/telemetry/session-telemetry/tsconfig.json b/packages/telemetry/session-telemetry/tsconfig.json new file mode 100644 index 0000000000..2c18a582e4 --- /dev/null +++ b/packages/telemetry/session-telemetry/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edd75466c5..0e96b9cf4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -318,6 +318,9 @@ importers: '@deepseek-ai/dsh-session-query-sqlite': specifier: workspace:* version: link:../packages/session-query/session-query-sqlite + '@deepseek-ai/dsh-session-telemetry-otel': + specifier: workspace:* + version: link:../packages/telemetry/session-telemetry-otel '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:* version: link:../packages/session-title/session-title-first-message-llm @@ -3538,6 +3541,61 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/telemetry/session-telemetry: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/telemetry/session-telemetry-otel: + dependencies: + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 + '@opentelemetry/api-logs': + specifier: ^0.220.0 + version: 0.220.0 + '@opentelemetry/exporter-logs-otlp-http': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: ^2.9.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-telemetry': + specifier: workspace:^ + version: link:../session-telemetry + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/timeout/timeout-policy: devDependencies: '@deepseek-ai/dsh-invariants': @@ -5803,10 +5861,78 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@opentelemetry/api-logs@0.220.0': + resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.10.0': + resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.9.0': + resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-http@0.220.0': + resolution: {integrity: sha512-8186thl+pTw64iz/qEEen5oJZoZ/gO73XruChdaGlYdWOdBIQ42r+vHLf6a7vIDqTD4b8ZOoMlyxptanECaI9A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.220.0': + resolution: {integrity: sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.220.0': + resolution: {integrity: sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.10.0': + resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.9.0': + resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.220.0': + resolution: {integrity: sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.9.0': + resolution: {integrity: sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.9.0': + resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/semantic-conventions@1.43.0': resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} @@ -10711,8 +10837,82 @@ snapshots: '@nodable/entities@2.2.0': {} + '@opentelemetry/api-logs@0.220.0': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api@1.9.0': {} + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-logs-otlp-http@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-exporter-base@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/semantic-conventions@1.43.0': {} '@oxc-parser/binding-android-arm-eabi@0.133.0': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a3274a83c3..4912729ff4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -221,6 +221,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts', SubagentRunEndInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts', SubagentRunInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts', + TelemetryRecord: 'seam-local record contract is owned by packages/telemetry/session-telemetry/src/index.ts', WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1f54f7358f..075f394565 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -77,6 +77,7 @@ const GROUP_ORDER = [ 'session-persistence', 'session-query', 'session-title', + 'telemetry', 'support', 'ui', ] @@ -132,6 +133,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query', 'session-query-sqlite'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, + { + key: 'telemetry', + pkg: 'session-telemetry', + title: 'Session telemetry seam', + mode: 'seam', + implementations: ['session-telemetry-otel'], + consumers: [], + note: 'The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process.', + }, { key: 'sessionQuery', pkg: 'session-query', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index d5fa282a24..42792f14ca 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -80,6 +80,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, + 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, + 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, 'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 56ab3ffef3..e33ef26038 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -74,6 +74,7 @@ "./packages/hooks/*/src/invariant.ts", "./packages/session-persistence/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", + "./packages/telemetry/*/src/invariant.ts", "./packages/sdk/*/src/invariant.ts", "./packages/ui/*/src/invariant.ts", "./packages/examples/*/src/invariant.ts", @@ -142,6 +143,7 @@ "./packages/session-persistence/*/src", "./packages/session-query/*/src", "./packages/session-title/*/src", + "./packages/telemetry/*/src", "./packages/sdk/*/src", "./packages/ui/*/src", "./packages/examples/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index a13bcf35e3..465b38edf4 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -48,6 +48,8 @@ { "path": "./packages/session-title/session-title-llm" }, { "path": "./packages/session-title/session-title-first-message-llm" }, { "path": "./packages/session-title/session-title-all-messages-llm" }, + { "path": "./packages/telemetry/session-telemetry" }, + { "path": "./packages/telemetry/session-telemetry-otel" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/ui/commands" }, From 70febffe1ad87f539cc50e0f5ea1d1cde5b0dc91 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 23 Jul 2026 11:58:47 +0800 Subject: [PATCH 02/70] refactor(telemetry): ship the redact waterfall without built-in rules The seam keeps the telemetry/redact scrubbing interface but ships no rules of its own: the innermost next() passes records through unchanged, and deployments mount their rules as waterfall listeners. As an SDK we cannot know which patterns are secrets in a given deployment; a shipped list invites false confidence while catching only known shapes, and false positives would corrupt exported bodies. Mechanism stays with the seam, policy moves to the deployment; both READMEs and the Agent Note state the raw-export default plainly. The loader-composition e2e now mounts a deployment-style rule fixture and pins the same wire behavior: secret absent, placeholder present, canonical log untouched. --- ...3-session-telemetry-otel-revival.i18n.yaml | 4 +- ...26-07-23-session-telemetry-otel-revival.md | 10 +- ...07-23-session-telemetry-otel-revival.zh.md | 10 +- docs/cordis-catalog/events.md | 25 +-- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- .../tests/fixtures/telemetry-otel.cordis.yml | 5 + .../tests/fixtures/telemetry-redact-rule.ts | 29 ++++ knip.json | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/telemetry/README.md | 2 +- .../session-telemetry-otel/README.md | 2 +- .../tests/loader-composition.e2e.ts | 15 +- .../telemetry/session-telemetry/README.md | 4 +- .../session-telemetry/src/coordinator.ts | 18 +- .../telemetry/session-telemetry/src/index.ts | 22 +-- .../telemetry/session-telemetry/src/redact.ts | 77 --------- .../session-telemetry/tests/redact.spec.ts | 159 +++++++----------- 18 files changed, 155 insertions(+), 236 deletions(-) create mode 100644 examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts delete mode 100644 packages/telemetry/session-telemetry/src/redact.ts diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index 31a5914d0d..e5c566662a 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -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 -2026-07-23-session-telemetry-otel-revival.md: 1150d363e9db98a39e1188b86dc41fa291edba41 -2026-07-23-session-telemetry-otel-revival.zh.md: 76749a59c38ef2b8c5b3f09960ca500c25d8e1a8 +2026-07-23-session-telemetry-otel-revival.md: 28a88218566dcfe34a3b99b682a4c21d22e00ce2 +2026-07-23-session-telemetry-otel-revival.zh.md: 79428c5fbb76bdd8f3c2d0b856edd712b6a650d0 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index 1150d363e9..28a8821856 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -10,10 +10,10 @@ Every deployment that wants harness sessions in an observability stack must hand ## Decision -`packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go, and nothing crosses the seam unredacted: +`packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go and owns what leaves in them: - **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: adoption with cursor read-back, the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), the fixed first-chunk-per-(turn, step) projection, the `agent/error` relay, and dispose-time `shutdown` records. -- **The `telemetry/redact` waterfall** — the delta over the branch version. Every record passes it before reaching any backend; the innermost `next()` applies a conservative built-in rule set (credential shapes: API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo), deployments stack stricter rules as listeners, and a throwing rule withholds the record fail-closed. The pattern list is a security invariant, deliberately not configurable. Redaction applies to the exported copy only; the canonical log is never rewritten. +- **The `telemetry/redact` waterfall** — the delta over the branch version. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten. - **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `exporter.url` is required and validated at load; unmounted or unconfigured, nothing leaves the process. The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs — delivery is best-effort (at-most-once across a crash), which the READMEs state plainly. @@ -22,12 +22,12 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry **Implement the runtime-telemetry RFC's outbox (durable spool, per-sink cursors, at-least-once, a `readCommitted` persistence-seam method).** Deferred, not rejected: the SDK stance makes delivery semantics the reporting SDK's territory, and the OTel SDK's own batch pipeline is the honest default. The outbox is a pure additive layer (the `emit()` contract does not move); revive it when a deployment states a crash-loss requirement telemetry must satisfy. -**Export without built-in redaction, delegating to receiver-side collector processors.** Rejected — this is what legal declined. Receiver-side redaction ships the secret first and scrubs it second; the seam must scrub before bytes leave the process, and a waterfall makes the redaction point auditable and stackable. +**No in-process redaction point, delegating to receiver-side collector processors.** Rejected — receiver-side redaction ships the secret first and scrubs it second. The waterfall puts an auditable, stackable scrubbing point before bytes leave the process; where the branch version (what PR #222 shipped) had no redaction point at all, every record now passes one. -**A configurable pattern list for the default rules.** Rejected: deployment-varying tunables belong in config, but a security invariant does not — weakening the floor should require code, not YAML. Stricter rules stack as `telemetry/redact` listeners. +**A built-in conservative rule set as the waterfall's innermost `next()`.** Rejected: as an SDK we cannot know which patterns are secrets in a given deployment, a shipped list invites false confidence ("redaction is on") while catching only known shapes, and false positives would corrupt exported bodies for consumers who never asked. The seam owns the mechanism; the deployment owns the policy — the innermost `next()` is a pass-through, and rules mount as listeners. **Map onto OTel spans (GenAI semantic conventions) instead of logs.** Rejected for this revival: the branch implementation's log mapping is reviewed and shipped-shaped; the span model is lossy for forkable, interruptible sessions and belongs to a future consumer with real span queries to serve. ## Consequences -A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. Credential-shaped substrings never leave the process even on a rule-free deployment, at the cost of a synchronous per-record scrub on the capture path (string-regex over lossless-JSON bodies — bounded by event size, no I/O). Exported bodies can differ from canonical log bytes wherever the placeholder landed, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. +A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/redact` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index 76749a59c3..79428c5fbb 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -10,10 +10,10 @@ Status: implemented ## Decision -`packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向,且任何数据未经脱敏不得跨越 seam: +`packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向并对导出内容负责: - **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的收养、逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、固定的每 (turn, step) 首 chunk 投影、`agent/error` 转发、以及 dispose 时的 `shutdown` 记录。 -- **`telemetry/redact` waterfall** —— 相对分支版本的增量。每条记录抵达任何 backend 前必经此处;最内层 `next()` 应用保守的内置规则集(凭据形状:API key、GitHub/Slack token、AWS/Google key、JWT、PEM 块、URL userinfo),部署方以监听器堆叠更严规则,抛异常的规则将该记录 fail-closed 扣下。模式列表是安全不变量,刻意不可配置。脱敏只作用于导出副本;canonical log 永不改写。 +- **`telemetry/redact` waterfall** —— 相对分支版本的增量。每条记录抵达任何 backend 前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 - **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程。 边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。 @@ -22,12 +22,12 @@ Status: implemented **实现 runtime-telemetry RFC 的 outbox(落盘 spool、每 sink 游标、at-least-once、persistence seam 的 `readCommitted` 方法)。** 推迟而非否决:SDK 立场使投递语义归属 reporting SDK,OTel SDK 自身的批处理管线是诚实的默认。outbox 是纯增量层(`emit()` 契约不动);待某个部署提出遥测必须满足的崩溃丢失要求时再复活。 -**不带内置脱敏直接导出,交给接收端 collector processor。** 否决——这正是法务否掉的方案。接收端脱敏是先把秘密发出去再擦除;seam 必须在字节离开进程前擦除,且 waterfall 使脱敏点可审计、可堆叠。 +**不设进程内脱敏点,交给接收端 collector processor。** 否决——接收端脱敏是先把秘密发出去再擦除。waterfall 在字节离开进程前提供一个可审计、可堆叠的擦除点;分支版本(PR #222 交付的形态)完全没有脱敏点,如今每条记录都必经其一。 -**默认规则的模式列表做成可配置。** 否决:随部署变化的调优项应进 config,但安全不变量不应——削弱底线应当需要改代码而非改 YAML。更严格的规则以 `telemetry/redact` 监听器堆叠。 +**在 waterfall 最内层 `next()` 内置一套保守规则集。** 否决:作为 SDK 我们无法预知某个部署里什么模式算秘密,内置列表只覆盖已知形状却会带来"脱敏已开启"的虚假信心,且误报会替从未要求过的消费者破坏导出 body。seam 拥有机制,部署方拥有策略——最内层 `next()` 原样透传,规则以监听器挂载。 **映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 ## Consequences -部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。即使部署方未配置任何规则,凭据形状的子串也绝不离开进程,代价是捕获路径上每条记录一次同步擦除(对 lossless-JSON body 做字符串正则——受事件大小约束,无 I/O)。导出的 body 在占位符落点处可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/redact` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4ed5de022b..5356f07a5b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -807,20 +807,21 @@ Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/syst ### `telemetry/redact` — waterfall -Redact one outbound record before it reaches the backend. The innermost `next()` applies the seam's conservative default rule set (credential-shape scrubbing); listeners stack stricter rules by transforming its return value, and returning without `next()` replaces the default — the exported record is then only as clean as the replacing rule. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten. +Redact one outbound record before it reaches the backend — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten. ```ts cordis-catalog /** - * Redact one outbound record before it reaches the backend. The innermost - * `next()` applies the seam's conservative default rule set - * (credential-shape scrubbing); listeners stack stricter rules by - * transforming its return value, and returning without `next()` replaces - * the default — the exported record is then only as clean as the - * replacing rule. Dispatched synchronously on the capture hot path inside - * the coordinator's containment: a throwing listener withholds that one - * record (fail-closed) and never reaches the agent loop. Redaction - * applies to the exported copy only; the canonical session log is never - * rewritten. + * Redact one outbound record before it reaches the backend — the seam's + * scrubbing extension point. The seam ships NO rules of its own: the + * innermost `next()` passes the record through unchanged, and with no + * listener mounted records reach the backend as captured, so exported + * data is exactly as clean as the rules a deployment mounts. Listeners + * stack by transforming `next()`'s return value; returning without + * `next()` replaces everything beneath. Dispatched synchronously on the + * capture hot path inside the coordinator's containment: a throwing + * listener withholds that one record (fail-closed) and never reaches the + * agent loop. Redaction applies to the exported copy only; the canonical + * session log is never rewritten. * @param record - the candidate record, already the coordinator's own deep * copy; listeners return a (possibly new) record and must not mutate it. * @mode waterfall @@ -828,7 +829,7 @@ Redact one outbound record before it reaches the backend. The innermost `next()` 'telemetry/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:39`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:40`](../../packages/telemetry/session-telemetry/src/index.ts) ## `tools/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 22120b2f8e..3dec10f786 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1554,7 +1554,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:123`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:124`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 30380261fe..da367e81f2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -43,7 +43,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `telemetry/redact` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:39`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | +| `telemetry/redact` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:40`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:143`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml index defde98ff5..ff81d0116c 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -1,8 +1,13 @@ # Test-only composition: session-telemetry-otel through the real Loader/app # path, exporting to the mock OTLP collector the driver starts (url via env). +# The redact-rule entry models a deployment mounting its own scrub rule on the +# telemetry/redact waterfall — the seam itself ships no rules. - id: cli-mock-llm name: './cli-mock-llm.ts' +- id: telemetry-redact-rule + name: './telemetry-redact-rule.ts' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts new file mode 100644 index 0000000000..10b7081dd3 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts @@ -0,0 +1,29 @@ +import type { Context } from 'cordis' + +/** + * Deployment-style redaction rule for the telemetry e2e: scrubs the fixture + * credential from body strings, exactly as a real deployment would mount its + * own rules on the `telemetry/redact` waterfall. + */ + +const SECRET = /sk-e2efixture[0-9]+/g +const PLACEHOLDER = '[E2E-REDACTED]' + +function scrub(value: unknown): unknown { + if (typeof value === 'string') return value.replace(SECRET, PLACEHOLDER) + if (Array.isArray(value)) return value.map(scrub) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, scrub(entry)])) + } + return value +} + +export const name = 'telemetry-redact-rule' + +/** Mount the fixture scrub rule onto the redact waterfall. */ +export function apply(ctx: Context): void { + ctx.on('telemetry/redact', (_record, next) => { + const record = next() + return { ...record, body: scrub(record.body) } + }) +} diff --git a/knip.json b/knip.json index 728ded39b7..753b3dda34 100644 --- a/knip.json +++ b/knip.json @@ -32,6 +32,7 @@ "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", "headless-agent/tests/fixtures/telemetry-otel-driver.ts", + "headless-agent/tests/fixtures/telemetry-redact-rule.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index aee1ac9ae8..f8377ffd54 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1124,8 +1124,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'telemetry/redact', mode: 'waterfall', signature: '\'telemetry/redact\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord', - jsDoc: '/**\n * Redact one outbound record before it reaches the backend. The innermost\n * `next()` applies the seam\'s conservative default rule set\n * (credential-shape scrubbing); listeners stack stricter rules by\n * transforming its return value, and returning without `next()` replaces\n * the default — the exported record is then only as clean as the\n * replacing rule. Dispatched synchronously on the capture hot path inside\n * the coordinator\'s containment: a throwing listener withholds that one\n * record (fail-closed) and never reaches the agent loop. Redaction\n * applies to the exported copy only; the canonical session log is never\n * rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */', - summary: 'Redact one outbound record before it reaches the backend.', + jsDoc: '/**\n * Redact one outbound record before it reaches the backend — the seam\'s\n * scrubbing extension point. The seam ships NO rules of its own: the\n * innermost `next()` passes the record through unchanged, and with no\n * listener mounted records reach the backend as captured, so exported\n * data is exactly as clean as the rules a deployment mounts. Listeners\n * stack by transforming `next()`\'s return value; returning without\n * `next()` replaces everything beneath. Dispatched synchronously on the\n * capture hot path inside the coordinator\'s containment: a throwing\n * listener withholds that one record (fail-closed) and never reaches the\n * agent loop. Redaction applies to the exported copy only; the canonical\n * session log is never rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */', + summary: 'Redact one outbound record before it reaches the backend — the seam\'s scrubbing extension point.', }, { name: 'tools/change', diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md index a5869077c2..ecb348d256 100644 --- a/packages/telemetry/README.md +++ b/packages/telemetry/README.md @@ -1,6 +1,6 @@ # telemetry/ -Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The design — the boundary axiom (the harness's aspect ends at `emit()`; delivery is the reporting SDK's), the mandatory `telemetry/redact` waterfall, the fixed chunk projection, the handoff cursor, and the operational-record channel — is pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). +Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The design — the boundary axiom (the harness's aspect ends at `emit()`; delivery is the reporting SDK's), the `telemetry/redact` waterfall (deployment-mounted rules; the seam ships none), the fixed chunk projection, the handoff cursor, and the operational-record channel — is pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). | Package | Role | |---|---| diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 26edc62645..3fe9f34626 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -19,7 +19,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th ## What leaves the machine -Records carry the seam's REDACTED copy of `event.data` — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, and the session `cwd` (a local path) — after the seam's `telemetry/redact` waterfall has scrubbed credential-shaped substrings (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. A deployment with stricter requirements stacks `telemetry/redact` listeners or opts out structurally. +Records carry the complete `event.data` as the seam's `telemetry/redact` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/redact` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. ## Field mapping diff --git a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts index 2c72fd6170..8f16662614 100644 --- a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts +++ b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts @@ -3,8 +3,8 @@ * a subprocess (per testing policy, through the same app/boot path a * deployment uses), run one mocked-model turn with a real bash round trip, * and assert against what the mock OTLP collector actually received on the - * wire: ledger mirroring, default redaction, ops markers, and the untouched - * canonical log. + * wire: ledger mirroring, the deployment-mounted redact rule applied to the + * exported copy, ops markers, and the untouched canonical log. */ import { readFile, readdir } from 'node:fs/promises' @@ -12,7 +12,6 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { REDACTION_PLACEHOLDER } from '@deepseek-ai/dsh-session-telemetry' const driver = fileURLToPath(new URL( '../../../../examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts', @@ -25,6 +24,7 @@ const configPath = fileURLToPath(new URL( const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) const FIXTURE_SECRET = 'sk-e2efixture1234567890' +const FIXTURE_PLACEHOLDER = '[E2E-REDACTED]' interface OtlpLogRecord { attributes?: { key: string; value: Record }[] @@ -84,15 +84,16 @@ describe('session-telemetry-otel through a real headless cordis.yml', () => { } expect(records.some(({ scope }) => scope.endsWith('/ops'))).toBe(true) - // Default redaction on the wire: the fixture credential never leaves the - // process, its surrounding prose does, and the placeholder marks the spot. + // The deployment-mounted rule on the wire: the fixture credential never + // leaves the process, its surrounding prose does, and the placeholder + // marks the spot — the seam itself ships no rules. const wire = JSON.stringify(captures) expect(wire).not.toContain(FIXTURE_SECRET) - expect(wire).toContain(REDACTION_PLACEHOLDER) + expect(wire).toContain(FIXTURE_PLACEHOLDER) expect(wire).toContain('prove telemetry with key') // The canonical session log is never rewritten. expect(logContent).toContain(FIXTURE_SECRET) - expect(logContent).not.toContain(REDACTION_PLACEHOLDER) + expect(logContent).not.toContain(FIXTURE_PLACEHOLDER) }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index bccc61fd06..0f3fe0ec28 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -12,7 +12,7 @@ The coordinator registers, all through the composing fiber's effects: `session/c ## The redact waterfall -Every record passes the `telemetry/redact` waterfall between projection and `emit()` — nothing reaches a backend unredacted. The innermost `next()` applies the built-in conservative rule set (`applyDefaultRedaction`: credential shapes — API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo — replaced with `[REDACTED]` in body strings and string attribute values). Listeners stack stricter rules by transforming `next()`'s return value; returning without `next()` replaces the default rule set, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. The built-in pattern list is a security invariant, deliberately not configurable from cordis.yml. Redaction applies to the exported copy only; the canonical session log is never rewritten. +Every record passes the `telemetry/redact` waterfall between projection and `emit()` — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Redaction applies to the exported copy only; the canonical session log is never rewritten. ## The handoff cursor @@ -37,4 +37,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Best-effort delivery** — the cursor marks handed-off, not delivered; a session torn down inside a reload window cannot be re-adopted; whatever sits in a backend queue at crash time is lost. A durable outbox (spool, per-sink cursors, at-least-once) is deferred until a deployment states a crash-loss requirement — see [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). -- **Redaction is shape-based** — the default rules catch known credential shapes, not every secret; a deployment with stricter needs stacks `telemetry/redact` listeners, and exported data is only as clean as the mounted rules. +- **No built-in redaction rules** — with no `telemetry/redact` listener mounted, records leave the process exactly as captured, including any credentials embedded in file contents or command output; a deployment exporting to a shared collector owns its rule set. diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 08fbe498b1..8e464c6add 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -2,10 +2,11 @@ * Capture coordinator: the seam's upstream half. Subscribes to the session * firehose plus the one live-bus relay (`agent/error`), applies the fixed * chunk projection, builds logical records, runs each through the - * `telemetry/redact` waterfall, and hands the redacted copy to the backend — - * synchronously, with every handler self-contained so a failing backend can - * never starve other subscribers (cordis `emit` is stop-on-throw) or touch - * the agent loop. Composed by a backend in its constructor. + * `telemetry/redact` waterfall (deployment-mounted rules; pass-through when + * none), and hands the result to the backend — synchronously, with every + * handler self-contained so a failing backend can never starve other + * subscribers (cordis `emit` is stop-on-throw) or touch the agent loop. + * Composed by a backend in its constructor. * * @module @deepseek-ai/dsh-session-telemetry/coordinator */ @@ -14,7 +15,6 @@ import type { Context } from 'cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { TelemetryBackend, TelemetryRecord, TelemetrySeverity } from './index.ts' -import { applyDefaultRedaction } from './redact.ts' /** * The handoff cursor: per session, the highest `seq` handed to a backend. @@ -145,13 +145,13 @@ export class TelemetryCoordinator { /** * Run the `telemetry/redact` waterfall over one record and hand the result - * to the backend. The innermost `next` applies the seam's conservative - * default rules, so an unconfigured deployment still never exports raw - * credential shapes; callers run inside {@link contain}, so a throwing + * to the backend. The innermost `next` passes the record through unchanged + * — the seam ships no rules; exported data is as clean as the listeners a + * deployment mounts. Callers run inside {@link contain}, so a throwing * rule withholds the record instead of reaching the loop (fail-closed). */ private handOff(record: TelemetryRecord): void { - this.backend.emit(this.ctx.waterfall('telemetry/redact', record, () => applyDefaultRedaction(record))) + this.backend.emit(this.ctx.waterfall('telemetry/redact', record, () => record)) } /** Forward the turn-end boundary to the backend's optional flush hint. */ diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index 93e7e7b0f9..65f5868e04 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -22,16 +22,17 @@ declare module 'cordis' { interface Events { /** - * Redact one outbound record before it reaches the backend. The innermost - * `next()` applies the seam's conservative default rule set - * (credential-shape scrubbing); listeners stack stricter rules by - * transforming its return value, and returning without `next()` replaces - * the default — the exported record is then only as clean as the - * replacing rule. Dispatched synchronously on the capture hot path inside - * the coordinator's containment: a throwing listener withholds that one - * record (fail-closed) and never reaches the agent loop. Redaction - * applies to the exported copy only; the canonical session log is never - * rewritten. + * Redact one outbound record before it reaches the backend — the seam's + * scrubbing extension point. The seam ships NO rules of its own: the + * innermost `next()` passes the record through unchanged, and with no + * listener mounted records reach the backend as captured, so exported + * data is exactly as clean as the rules a deployment mounts. Listeners + * stack by transforming `next()`'s return value; returning without + * `next()` replaces everything beneath. Dispatched synchronously on the + * capture hot path inside the coordinator's containment: a throwing + * listener withholds that one record (fail-closed) and never reaches the + * agent loop. Redaction applies to the exported copy only; the canonical + * session log is never rewritten. * @param record - the candidate record, already the coordinator's own deep * copy; listeners return a (possibly new) record and must not mutate it. * @mode waterfall @@ -142,4 +143,3 @@ export abstract class Telemetry extends Service implements TelemetryBackend { } export { TelemetryCoordinator } from './coordinator.ts' -export { applyDefaultRedaction, REDACTION_PLACEHOLDER } from './redact.ts' diff --git a/packages/telemetry/session-telemetry/src/redact.ts b/packages/telemetry/session-telemetry/src/redact.ts deleted file mode 100644 index d25452581a..0000000000 --- a/packages/telemetry/session-telemetry/src/redact.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Conservative default redaction for outbound telemetry records. - * - * Session-event bodies carry file contents and command output that may embed - * credentials; nothing may cross the seam to a backend unredacted. This module - * is the innermost rule set of the `telemetry/redact` waterfall — always - * applied unless an outer listener deliberately replaces the whole chain. It - * scrubs credential-SHAPED substrings from every string in the record body, - * leaving structure (keys, nesting, surrounding prose) intact. The pattern - * list is a security invariant, deliberately not configurable; deployments - * add stricter rules by stacking `telemetry/redact` listeners. - * - * @module @deepseek-ai/dsh-session-telemetry/redact - */ - -import type { TelemetryRecord } from './index.ts' - -/** Replacement text substituted for each detected credential-shaped span. */ -export const REDACTION_PLACEHOLDER = '[REDACTED]' - -/** - * Well-known credential shapes. A match anywhere inside a body string is - * replaced; low-signal values (package names, versions, git SHAs, plain URLs) - * deliberately stay untouched — they are the observability signal. - */ -const SECRET_PATTERNS: readonly RegExp[] = [ - /sk-(?:ant-)?[A-Za-z0-9_-]{10,}/g, // DeepSeek / OpenAI / Anthropic API keys - /gh[pousr]_[A-Za-z0-9]{16,}/g, // GitHub personal/oauth/server/refresh tokens - /github_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT - /xox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens - /AKIA[0-9A-Z]{16}/g, // AWS access key id - /AIza[0-9A-Za-z_-]{35}/g, // Google API key - /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, // JWT - /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, // PEM blocks - /\b(?[a-z][a-z0-9+.-]*):\/\/[^/\s:@]+:[^/\s:@]+@/g, // URL userinfo credentials -] - -/** Replace every known credential shape inside one string. */ -function scrub(text: string): string { - let out = text - for (const pattern of SECRET_PATTERNS) { - out = out.replace(pattern, REDACTION_PLACEHOLDER) - } - return out -} - -/** - * Deep-scrub every string inside a lossless-JSON value, preserving structure. - * The record body is the coordinator's own `structuredClone` — mutation-free - * rebuilding keeps the exported copy independent of the canonical log either way. - */ -function scrubValue(value: unknown): unknown { - if (typeof value === 'string') return scrub(value) - if (Array.isArray(value)) return value.map(scrubValue) - if (value !== null && typeof value === 'object') { - const out: Record = {} - for (const [key, entry] of Object.entries(value)) out[key] = scrubValue(entry) - return out - } - return value -} - -/** - * Apply the conservative default rule set to one record — the innermost - * `next` of the `telemetry/redact` waterfall. Attribute VALUES are scrubbed - * alongside the body (identity attributes are seam-built and boring, but - * `session.cwd` is caller-supplied); attribute keys are seam-owned constants. - * @param record - the candidate record; not mutated. - * @returns a redacted copy safe to hand to a backend. - */ -export function applyDefaultRedaction(record: TelemetryRecord): TelemetryRecord { - const attributes: Record = {} - for (const [key, value] of Object.entries(record.attributes)) { - attributes[key] = typeof value === 'string' ? scrub(value) : value - } - return { ...record, attributes, body: scrubValue(record.body) } -} diff --git a/packages/telemetry/session-telemetry/tests/redact.spec.ts b/packages/telemetry/session-telemetry/tests/redact.spec.ts index cd23af6da2..c4b9d4d5bb 100644 --- a/packages/telemetry/session-telemetry/tests/redact.spec.ts +++ b/packages/telemetry/session-telemetry/tests/redact.spec.ts @@ -1,91 +1,19 @@ /** - * Default redaction rules and the `telemetry/redact` waterfall contract: - * credential shapes scrubbed from bodies and attribute values, structure - * preserved, canonical log untouched, listener stacking/replacement, and the - * fail-closed containment of a throwing rule. + * The `telemetry/redact` waterfall contract: pass-through when no listener is + * mounted, listener stacking and replacement, ops-record coverage, the + * untouched canonical log, and the fail-closed containment of a throwing rule. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { - applyDefaultRedaction, - REDACTION_PLACEHOLDER, TelemetryCoordinator, type TelemetryBackend, type TelemetryRecord, } from '../src/index.ts' -const SECRETS = { - deepseek: 'sk-abcdef1234567890abcdef', - anthropic: 'sk-ant-abcdef1234567890', - githubPat: 'ghp_ABCDEFGHIJKLMNOPqrstuv12345678', - finePat: 'github_pat_ABCDEFGHIJKLMNOPQRSTuvwx', - slack: 'xoxb-1234567890-abcdefghij', - aws: 'AKIAIOSFODNN7EXAMPLE', - google: 'AIzaSyA-1234567890abcdefghijklmnopqrstu', - jwt: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpM', - pem: '-----BEGIN RSA PRIVATE KEY-----\nMIIEow\n-----END RSA PRIVATE KEY-----', - urlCreds: 'https://user:hunter2@internal.example.com/repo.git', -} as const - -function record(body: unknown, attributes: Record = {}): TelemetryRecord { - return { channel: 'ledger', time: 1, severity: 'info', attributes, body } -} - -describe('applyDefaultRedaction', () => { - it('scrubs every known credential shape while preserving surrounding text', () => { - for (const secret of Object.values(SECRETS)) { - const out = applyDefaultRedaction(record(`before ${secret} after`)) - expect(out.body, secret).not.toContain(secret.includes('\n') ? 'MIIEow' : secret) - expect(out.body).toContain('before ') - expect(out.body).toContain(' after') - expect(out.body).toContain(REDACTION_PLACEHOLDER) - } - }) - - it('scrubs URL userinfo credentials but leaves plain URLs alone', () => { - const out = applyDefaultRedaction(record(`${SECRETS.urlCreds} and https://example.com/path`)) - expect(out.body).not.toContain('hunter2') - expect(out.body).toContain('https://example.com/path') - }) - - it('recurses through arrays and objects, preserving structure and non-strings', () => { - const out = applyDefaultRedaction(record({ - list: [`key=${SECRETS.deepseek}`, 7, null, true], - nested: { text: SECRETS.githubPat, count: 3 }, - })) - expect(out.body).toEqual({ - list: [`key=${REDACTION_PLACEHOLDER}`, 7, null, true], - nested: { text: REDACTION_PLACEHOLDER, count: 3 }, - }) - }) - - it('leaves low-signal values untouched', () => { - const clean = { - pkg: '@deepseek-ai/dsh-session-telemetry@0.0.1', - sha: '342a4c3a9d3adf13cf4ad33b9f8d6e79170be5e2', - prose: 'ordinary sentence with kebab-case-identifier', - } - expect(applyDefaultRedaction(record(clean)).body).toEqual(clean) - }) - - it('scrubs string attribute values and keeps numeric ones', () => { - const out = applyDefaultRedaction(record(null, { - 'session.cwd': `/home/${SECRETS.aws}/proj`, - 'event.seq': 4, - })) - expect(out.attributes['session.cwd']).toBe(`/home/${REDACTION_PLACEHOLDER}/proj`) - expect(out.attributes['event.seq']).toBe(4) - }) - - it('never mutates its input', () => { - const input = record({ text: SECRETS.slack }, { 'session.cwd': SECRETS.aws }) - applyDefaultRedaction(input) - expect((input.body as { text: string }).text).toBe(SECRETS.slack) - expect(input.attributes['session.cwd']).toBe(SECRETS.aws) - }) -}) +const FIXTURE_SECRET = 'sk-fixture1234567890' class CollectingBackend implements TelemetryBackend { records: TelemetryRecord[] = [] @@ -99,49 +27,80 @@ async function setup() { const backend = new CollectingBackend() const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin({ + const fiber = await ctx.plugin({ name: 'fake-telemetry', inject: ['sessions'], apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), }) - return { ctx, backend } + return { ctx, backend, fiber } } describe('telemetry/redact waterfall', () => { - it('applies the default rules when no listener is registered', async () => { + it('passes records through unchanged when no listener is mounted', async () => { const { ctx, backend } = await setup() const session = ctx.sessions.create(SessionId('w')) - session.append('user/message', { content: [{ type: 'text', text: `key ${SECRETS.deepseek}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('user/message', { content: [{ type: 'text', text: `key ${FIXTURE_SECRET}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const body = backend.records[0]!.body as { content: { text: string }[] } - expect(body.content[0]!.text).toBe(`key ${REDACTION_PLACEHOLDER}`) + expect(body.content[0]!.text).toBe(`key ${FIXTURE_SECRET}`) }) - it('keeps the canonical log unredacted', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create(SessionId('log')) - session.append('user/message', { content: [{ type: 'text', text: SECRETS.githubPat }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const logged = session.events[0]!.data as { content: { text: string }[] } - expect(logged.content[0]!.text).toBe(SECRETS.githubPat) - }) - - it('lets a listener stack a stricter rule on top of the defaults', async () => { - const { ctx, backend } = await setup() + it('applies a mounted rule to every outbound record, ops records included', async () => { + const { ctx, backend, fiber } = await setup() ctx.on('telemetry/redact', (_record, next) => { - const defaulted = next() - return { ...defaulted, body: { shapeOnly: true } } + const record = next() + return { ...record, body: { scrubbed: true } } + }) + const session = ctx.sessions.create(SessionId('rule')) + session.append('user/message', { content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records[0]!.body).toEqual({ scrubbed: true }) + // The dispose-time shutdown ops record passes through the same waterfall. + await fiber.dispose() + const ops = backend.records.filter(record => record.channel === 'ops') + expect(ops).toHaveLength(1) + expect(ops[0]!.body).toEqual({ scrubbed: true }) + }) + + it('keeps the canonical log untouched by a mounted rule', async () => { + const { ctx } = await setup() + ctx.on('telemetry/redact', (_record, next) => ({ ...next(), body: null })) + const session = ctx.sessions.create(SessionId('log')) + session.append('user/message', { content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const logged = session.events[0]!.data as { content: { text: string }[] } + expect(logged.content[0]!.text).toBe(FIXTURE_SECRET) + }) + + it('stacks listeners outermost-first around next()', async () => { + const { ctx, backend } = await setup() + const order: string[] = [] + ctx.on('telemetry/redact', (_record, next) => { + order.push('outer-before') + const record = next() + order.push('outer-after') + return { ...record, attributes: { ...record.attributes, outer: 1 } } + }) + ctx.on('telemetry/redact', (_record, next) => { + order.push('inner') + const record = next() + return { ...record, attributes: { ...record.attributes, inner: 1 } } }) const session = ctx.sessions.create(SessionId('stack')) - session.append('user/message', { content: [{ type: 'text', text: 'anything' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - expect(backend.records[0]!.body).toEqual({ shapeOnly: true }) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(order).toEqual(['outer-before', 'inner', 'outer-after']) + expect(backend.records[0]!.attributes).toMatchObject({ outer: 1, inner: 1 }) }) - it('a listener that skips next() replaces the default rules', async () => { + it('a listener that skips next() replaces everything beneath it', async () => { const { ctx, backend } = await setup() - ctx.on('telemetry/redact', record => record) + const inner = { called: false } + ctx.on('telemetry/redact', () => ({ channel: 'ops', time: 0, severity: 'info', attributes: {}, body: 'replaced' } satisfies TelemetryRecord)) + ctx.on('telemetry/redact', (_record, next) => { + inner.called = true + return next() + }) const session = ctx.sessions.create(SessionId('veto')) - session.append('user/message', { content: [{ type: 'text', text: SECRETS.slack }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const body = backend.records[0]!.body as { content: { text: string }[] } - expect(body.content[0]!.text).toBe(SECRETS.slack) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records[0]!.body).toBe('replaced') + expect(inner.called).toBe(false) }) it('a throwing rule withholds the record fail-closed without disturbing the log', async () => { From b5523b0b483aedc6b0287917c10b3f8254bdf5a4 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 23 Jul 2026 17:49:17 +0800 Subject: [PATCH 03/70] refactor(telemetry): drop dead live-collector smoke and the compact/end severity probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/otel.e2e.ts self-skipped on $DSH_OTLP_E2E_ENDPOINT, which nothing in the repo sets — it never ran; the mock-collector wire spec and the keyless Loader-composition e2e already cover the pipeline both ways. The severityOf compact/end probe parsed another package's merged event shape by string comparison — an untyped cross-package contract that breaks silently — and its only consumer was the test's own stand-in declaration. Unknown event types now uniformly fall through as info; outcome semantics stay with the owning package. --- docs/cordis-catalog/services.md | 2 +- .../session-telemetry-otel/README.md | 2 +- .../session-telemetry-otel/tests/otel.e2e.ts | 25 ------------------- .../telemetry/session-telemetry/README.md | 2 +- .../session-telemetry/src/coordinator.ts | 12 +++------ .../telemetry/session-telemetry/src/index.ts | 7 +++--- .../session-telemetry/tests/telemetry.spec.ts | 14 +++-------- 7 files changed, 14 insertions(+), 50 deletions(-) delete mode 100644 packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3dec10f786..b9943aaf06 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1554,7 +1554,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:124`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:125`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 3fe9f34626..da0e96d61f 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -36,4 +36,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Upstream experimental tree** — `@opentelemetry/sdk-logs` is still published from the upstream experimental tree; SDK API churn lands here and only here — the seam contract does not move. -- **Live-collector smoke is opt-in** — the e2e smoke (`tests/otel.e2e.ts`) self-skips without `$DSH_OTLP_E2E_ENDPOINT`; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape against a mock collector on every run. +- **No live-collector coverage** — every test exports to a local mock collector; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape on every run, and behavior against a real OTLP deployment (auth, TLS, throttling) is the SDK exporter's documented territory. diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts deleted file mode 100644 index 91c92b3486..0000000000 --- a/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Keyless-self-skipping smoke: ship one real session's records to a live - * OTLP collector named by $DSH_OTLP_E2E_ENDPOINT and require the SDK's - * shutdown (flush-and-quiesce) to resolve. Skipped without the endpoint so - * secretless CI stays green — a CI accommodation, not a cost signal. - */ - -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import TelemetryOtel from '../src/index.ts' - -describe.skipIf(!process.env.DSH_OTLP_E2E_ENDPOINT)('telemetry-otel e2e (live collector)', () => { - it('exports a session and quiesces cleanly', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(TelemetryOtel, { - exporter: { url: process.env.DSH_OTLP_E2E_ENDPOINT! }, - }) - const session = ctx.sessions.create(SessionId(`e2e-${Date.now()}`), { meta: {} }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await expect(fiber.dispose()).resolves.not.toThrow() - }) -}) diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 0f3fe0ec28..26a28baa2d 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -24,7 +24,7 @@ Only the first `assistant/chunk` of each `(turn, step)` ships; the rest are drop ## The logical record -`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError`, `turn/end` error reasons, `compact/end` errors; WARN for `prompt/blocked`; INFO otherwise), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `telemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`. +`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError` and `turn/end` error reasons; WARN for `prompt/blocked`; INFO otherwise, including plugin-merged event types whose outcome semantics stay with their owners), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `telemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`. ## Model Experience diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 8e464c6add..ba1610247d 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -218,15 +218,11 @@ function severityOf(event: SessionEvent): TelemetrySeverity { return event.data.reason.kind === 'error' ? 'error' : 'info' case 'prompt/blocked': return 'warn' - default: { - // Merge-extensible fall-through (no assertNever): types this seam does - // not depend on still get their RFC-pinned severity via a widened - // probe — `compact/end` is declared by dsh-compact, which the seam - // deliberately does not import. - const type: string = event.type - if (type === 'compact/end' && (event.data as { error?: unknown }).error !== undefined) return 'error' + default: + // Merge-extensible fall-through (no assertNever): event types this seam + // does not depend on — including plugin-merged ones it never heard of — + // pass through as info; their owners' outcome semantics stay theirs. return 'info' - } } } diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index 65f5868e04..09800aa1b5 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -44,9 +44,10 @@ declare module 'cordis' { /** * Severity of a telemetry record, pre-mapped at capture so a receiver can * alert with zero configuration: `error` for events whose own outcome flag - * says so (`tool/result.isError`, `turn/end` error reasons, `compact/end` - * errors) and for `agent-error` operational records, `warn` for - * `prompt/blocked`, `info` for everything else. + * says so (`tool/result.isError`, `turn/end` error reasons) and for + * `agent-error` operational records, `warn` for `prompt/blocked`, `info` + * for everything else — including event types merged by other packages, + * whose outcome semantics stay with their owners. */ export type TelemetrySeverity = 'info' | 'warn' | 'error' diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 9f694749ef..743e90073d 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -19,12 +19,6 @@ declare module '@deepseek-ai/dsh-session' { * @param payload - opaque test payload */ 'telemetry-test/opaque': { payload: { nested: string[] } } - /** - * Test-only stand-in for dsh-compact's merge, exercising the widened severity probe. - * @mode emit - * @param error - failure text when the compaction failed - */ - 'compact/end': { turn: number; error?: string } } } @@ -104,15 +98,14 @@ describe('TelemetryCoordinator capture', () => { } }) - it('maps outcome flags to severity, including the widened merge-extensible probe', async () => { + it('maps outcome flags to severity, unknown types falling through as info', async () => { const { ctx, backend } = await setup() const session = liveSession(ctx) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('tool/result', { turn: 1, step: 1, callId: 'c1' as never, content: [], isError: true }, { surfaceOp: 'append' }) session.append('tool/result', { turn: 1, step: 1, callId: 'c2' as never, content: [], isError: false }, { surfaceOp: 'append' }) session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' }) - session.append('compact/end', { turn: 1, error: 'summarizer died' }) - session.append('compact/end', { turn: 1 }) + session.append('telemetry-test/opaque', { payload: { nested: [] } }) session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity]) expect(severities).toEqual([ @@ -120,8 +113,7 @@ describe('TelemetryCoordinator capture', () => { ['tool/result', 'error'], ['tool/result', 'info'], ['prompt/blocked', 'warn'], - ['compact/end', 'error'], - ['compact/end', 'info'], + ['telemetry-test/opaque', 'info'], ['turn/end', 'error'], ]) }) From e6a8ff2621fa6de8685dc6e202440404b5dfb997 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sat, 25 Jul 2026 03:20:29 +0800 Subject: [PATCH 04/70] =?UTF-8?q?fix(telemetry):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20flush/shutdown=20ordering,=20session=20retirement,?= =?UTF-8?q?=20whole-exporter=20passthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, each pinned by a red test first: - The OTel backend retains the latest turn-boundary flush promise and shutdown() awaits it before provider.shutdown(): the SDK's concurrent-flush guard makes the shutdown-internal flush return early while one is in flight, silently dropping everything enqueued after the flush snapshot (including the coordinator's dispose-time shutdown markers). - The coordinator retires sessions from the adopted set on session/disposed (mirroring the persistence coordinator): a long-lived backend no longer retains closed sessions and their event logs, and final unload no longer stamps shutdown markers for dead sessions. - The exporter config passes through whole to OTLPLogExporter and its type/JSDoc now advertise the full OTLPExporterNodeConfigBase shape: rebuilding url/headers only silently dropped documented SDK options (timeoutMillis, compression, keepAlive, ...). --- docs/config-catalog.md | 15 ++-- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- .../session-telemetry-otel/README.md | 2 +- .../session-telemetry-otel/package.json | 1 + .../session-telemetry-otel/src/index.ts | 61 +++++++++------ .../session-telemetry-otel/tests/otel.spec.ts | 75 +++++++++++++++++-- .../telemetry/session-telemetry/README.md | 2 +- .../session-telemetry/src/coordinator.ts | 16 +++- .../telemetry/session-telemetry/src/index.ts | 10 ++- .../session-telemetry/tests/telemetry.spec.ts | 17 +++++ pnpm-lock.yaml | 3 + 12 files changed, 161 insertions(+), 45 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2f55f60bec..e017d1255a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1059,12 +1059,15 @@ Requires: `sessions` * must fail at plugin load, not at first export. */ export interface Config { - /** Passed verbatim to the SDK's OTLP/HTTP log exporter. */ - exporter?: { + /** + * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete + * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`, + * `compression`, `keepAlive`, …), owned and documented by the SDK. `url` + * is the one field this package requires and validates itself. + */ + exporter?: OTLPExporterNodeConfigBase & { /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ url?: string - /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */ - headers?: Record } /** * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, @@ -1074,9 +1077,9 @@ export interface Config { } ``` -Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) +Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:39`](../packages/telemetry/session-telemetry-otel/src/index.ts) +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:40`](../packages/telemetry/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b9943aaf06..bdd05ab65f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1554,7 +1554,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:125`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:129`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index da367e81f2..dcf536df7f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -34,7 +34,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index da0e96d61f..bd36c7acb3 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -15,7 +15,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th processor: {} # optional; passed verbatim to BatchLogRecordProcessor ``` -`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load. Everything else is the SDK's option shape, owned and documented by the SDK; batching, retry, queue bounds, and loss policy under sustained failure are its documented behavior, tuned through the `processor` passthrough. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. +`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load. Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. ## What leaves the machine diff --git a/packages/telemetry/session-telemetry-otel/package.json b/packages/telemetry/session-telemetry-otel/package.json index 706112d1fe..7be8c04ce4 100644 --- a/packages/telemetry/session-telemetry-otel/package.json +++ b/packages/telemetry/session-telemetry-otel/package.json @@ -30,6 +30,7 @@ "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/exporter-logs-otlp-http": "^0.220.0", + "@opentelemetry/otlp-exporter-base": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "schemastery": "^3.18.0" diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index 4738cdd932..8dde46828e 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -23,6 +23,7 @@ import { type BatchLogRecordProcessorOptions, } from '@opentelemetry/sdk-logs' import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' +import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base' import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs' import { resourceFromAttributes } from '@opentelemetry/resources' @@ -37,12 +38,15 @@ const { version } = createRequire(import.meta.url)('../package.json') as { versi * must fail at plugin load, not at first export. */ export interface Config { - /** Passed verbatim to the SDK's OTLP/HTTP log exporter. */ - exporter?: { + /** + * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete + * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`, + * `compression`, `keepAlive`, …), owned and documented by the SDK. `url` + * is the one field this package requires and validates itself. + */ + exporter?: OTLPExporterNodeConfigBase & { /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ url?: string - /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */ - headers?: Record } /** * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, @@ -54,15 +58,13 @@ export interface Config { /** * Schemastery validator for {@link Config}; cordis runs it before the plugin * starts. Shape-level only — the load-bearing `exporter.url` check lives in - * the constructor so its error message names the field. + * the constructor so its error message names the field. Both slots are opaque + * passthroughs: the SDK owns their shapes and validates its own options; + * re-declaring them field-by-field here would violate the boundary axiom + * (and silently drop every field not re-declared). */ export const Config: z = z.object({ - exporter: z.object({ - url: z.string(), - headers: z.dict(z.string()), - }), - // Opaque passthrough: the SDK owns this shape and validates its own - // options; re-declaring them here would violate the boundary axiom. + exporter: z.any(), processor: z.any(), }) @@ -112,14 +114,13 @@ export class TelemetryOtel extends Telemetry { processors: [ new BatchLogRecordProcessor({ ...config.processor, - exporter: new OTLPLogExporter({ - url, - // App identity travels in the Resource (service.name/version); - // the transport-level user-agent is the SDK's own, per the axiom. - // Schemastery fills `headers` with {} before cordis constructs the - // plugin, so the optional type exists for hand-authors only. - headers: config.exporter?.headers as Record, - }), + // The complete validated exporter object, verbatim: every SDK + // option (`timeoutMillis`, `compression`, `keepAlive`, …) reaches + // the exporter — rebuilding selected fields here would silently + // ignore the rest. App identity travels in the Resource + // (service.name/version); the transport-level user-agent is the + // SDK's own, per the axiom. + exporter: new OTLPLogExporter(config.exporter), }), ], }) @@ -146,22 +147,34 @@ export class TelemetryOtel extends Telemetry { }) } + /** The latest turn-boundary flush, retained so {@link shutdown} can order behind it. */ + private inflightFlush: Promise = Promise.resolve() + /** Forward the turn-boundary hint to the SDK's flush, fire-and-forget. */ override flush(): void { // Best-effort hint: the SDK resolves forceFlush even when exports fail // (failures go to its own diagnostics), and the coordinator stops calling - // this once the fiber is disposed — a rejection would be SDK drift. + // this once the fiber is disposed — a rejection would be SDK drift. The + // settled promise is retained (not awaited): the SDK's concurrent-flush + // guard makes a flush that overlaps another return WITHOUT draining, so + // shutdown must wait this one out before trusting its own flush. /* v8 ignore next -- unreachable guard: forceFlush does not reject while the provider is alive */ - void this.provider.forceFlush().catch(() => {}) + this.inflightFlush = this.provider.forceFlush().catch(() => {}) } /** * Delegate disposal to the SDK's shutdown contract: flush the queue and - * quiesce. Awaited (and error-contained) by the coordinator's disposer. + * quiesce. Orders behind the last turn-boundary flush first — shutdown's + * internal flush is a no-op while one is in flight (the SDK's + * concurrent-flush guard), which would silently drop everything enqueued + * after that flush snapshot, including the coordinator's dispose-time + * `shutdown` markers. Awaited (and error-contained) by the coordinator's + * disposer. * @returns resolves when the SDK pipeline has quiesced. */ - shutdown(): Promise { - return this.provider.shutdown() + async shutdown(): Promise { + await this.inflightFlush + await this.provider.shutdown() } } diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index 88a09a0f3b..b44b42ea04 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -8,6 +8,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { createServer, type Server } from 'node:http' import { once } from 'node:events' +import { gunzipSync } from 'node:zlib' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -43,17 +44,26 @@ afterEach(async () => { } }) -async function mockCollector(): Promise<{ url: string; captures: Capture[] }> { +async function mockCollector( + beforeRespond?: (requestIndex: number) => Promise | void, +): Promise<{ url: string; captures: Capture[] }> { const captures: Capture[] = [] + let requestIndex = 0 const server = createServer((request, response) => { const chunks: Buffer[] = [] request.on('data', chunk => chunks.push(chunk as Buffer)) request.on('end', () => { - captures.push({ - headers: request.headers, - body: JSON.parse(Buffer.concat(chunks).toString()) as OtlpLogsRequest, - }) - response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + const index = requestIndex++ + void (async () => { + await beforeRespond?.(index) + const raw = Buffer.concat(chunks) + const body = request.headers['content-encoding'] === 'gzip' ? gunzipSync(raw) : raw + captures.push({ + headers: request.headers, + body: JSON.parse(body.toString()) as OtlpLogsRequest, + }) + response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + })() }) }) servers.push(server) @@ -113,6 +123,59 @@ describe('TelemetryOtel wire', () => { expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) }) + it('delivers records enqueued while a turn-boundary flush is in flight (flush/shutdown race)', async () => { + // Hold the collector's response to the flush-triggered export open until + // after disposal has begun: the SDK's concurrent-flush guard makes the + // shutdown-internal flush return early while another flush is running, so + // without ordering in the backend the coordinator's dispose-time shutdown + // marker (enqueued after the flush snapshot) would be dropped silently. + const gate = Promise.withResolvers() + const arrived = Promise.withResolvers() + const { url, captures } = await mockCollector(async (index) => { + if (index === 0) { + arrived.resolve(true) + await gate.promise + } + }) + const { ctx, fiber } = await boot(url) + const session = ctx.sessions.create(SessionId('race'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + ctx.telemetry.flush!() + await arrived.promise + + const disposal = fiber.dispose() + // Let disposal reach the backend's shutdown while the export is held open. + await new Promise(resolve => setTimeout(resolve, 50)) + gate.resolve(true) + await disposal + + const records = allRecords(captures) + const ops = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops') + expect(ops).toHaveLength(1) + expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) + }) + + it('passes exporter options beyond url and headers through to the SDK exporter', async () => { + const { url, captures } = await mockCollector() + const ctx = new Context() + await ctx.plugin(SessionStore) + // `compression` is a documented SDK exporter option; the advertised + // verbatim passthrough must hand it (and every other field) to the + // exporter rather than silently rebuilding url/headers only. + const fiber = await ctx.plugin(TelemetryOtel, { + exporter: { url, compression: 'gzip' }, + } as Config) + const session = ctx.sessions.create(SessionId('gzip'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await fiber.dispose() + + expect(captures.length).toBeGreaterThan(0) + expect(captures[0]!.headers['content-encoding']).toBe('gzip') + const types = allRecords(captures).flatMap(({ record }) => + record.attributes?.flatMap(a => a.key === 'event.type' ? [a.value.stringValue] : []) ?? []) + expect(types).toContain('turn/start') + }) + it('maps the warn severity and forwards the flush hint to the SDK', async () => { const { url, captures } = await mockCollector() const { ctx, fiber } = await boot(url) diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 26a28baa2d..80667b308f 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -8,7 +8,7 @@ The telemetry seam: the CAPTURE side of session-event reporting, behind a backen ## Capture points -The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection — seed events from fork/resume never re-emit on the firehose), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (per adopted session emit its `shutdown` operational record, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). +The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection — seed events from fork/resume never re-emit on the firehose), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (retire: release the adopted entry so a long-lived backend neither retains closed sessions nor stamps dispose-time markers for them), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (per still-adopted session emit its `shutdown` operational record, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). ## The redact waterfall diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index ba1610247d..e8213e588a 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -35,13 +35,20 @@ const handoffCursor = new WeakMap() * Registers the persistence-coordinator listener set plus the `agent/error` * relay, all through `ctx.effect()`/`ctx.on()` on the composing fiber, and * sweeps already-live sessions (a hot reload does not replay - * `session/created`). Disposal emits each adopted session's `shutdown` + * `session/created`). A `session/disposed` retires the session from the + * adopted set — a long-lived backend must not retain closed sessions (and + * their frozen event logs) or stamp dispose-time markers for sessions that + * already ended. Disposal emits each still-adopted session's `shutdown` * operational record and then awaits the backend's `shutdown()`; a failure * there warns instead of throwing — best-effort reporting must not fail * application teardown. */ export class TelemetryCoordinator { - /** Sessions adopted by THIS fiber, for dispose-time `shutdown` records and double-adoption protection. */ + /** + * Sessions adopted by THIS fiber and still live, for dispose-time + * `shutdown` records and double-adoption protection; `session/disposed` + * retires entries. + */ private readonly adopted = new Set() /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */ private readonly chunkSeen = new WeakMap>() @@ -57,6 +64,11 @@ export class TelemetryCoordinator { ctx.on('session/created', (session) => { this.adopt(session) }) + // Retirement is observe-only: the projection/cursor WeakMaps die with the + // Session object; only the strong adopted set needs the explicit release. + ctx.on('session/disposed', (session) => { + this.adopted.delete(session) + }) ctx.on('session/event', (session, event) => { this.contain(() => { this.capture(session, event) diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index 09800aa1b5..6d92a9547d 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -108,9 +108,13 @@ export interface TelemetryBackend { flush?(): void /** * Forward the fiber's disposal to the SDK: flush whatever is queued and - * reach quiescence, per the SDK's own shutdown contract. Awaited by the - * coordinator's dispose; a rejection is logged as a warning and never - * fails application teardown. + * reach quiescence, per the SDK's own shutdown contract. Everything + * emitted before this call must still be delivered — including records + * enqueued while a {@link flush} hint is in flight, so a backend whose SDK + * guards against concurrent flushes orders behind the outstanding one (the + * coordinator emits its dispose-time `shutdown` markers immediately before + * calling this). Awaited by the coordinator's dispose; a rejection is + * logged as a warning and never fails application teardown. * @returns resolves when the backend's pipeline has quiesced. */ shutdown(): Promise diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 743e90073d..55b5ed694a 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -262,6 +262,23 @@ describe('TelemetryCoordinator lifecycle and containment', () => { expect(ops.every(r => !('event.seq' in r.attributes) && !('event.type' in r.attributes))).toBe(true) }) + it('retires a disposed session: no retention, no stale shutdown marker at unload', async () => { + const { ctx, backend, fiber } = await setup() + liveSession(ctx, 'survivor') + // A session owned by its own fiber: disposing the fiber detaches it from + // the store and emits `session/disposed` — the authoritative retirement + // signal a long-lived telemetry backend must honor, or every closed + // session (and its full event log) stays strongly held for the backend's + // lifetime and final unload emits shutdown markers for dead sessions. + const owner = await ctx.plugin(Object.assign((inner: Context) => { + inner.sessions.create(SessionId('ephemeral'), { meta: {} }) + }, { inject: ['sessions'] })) + await owner.dispose() + await fiber.dispose() + const ops = backend.records.filter(r => r.channel === 'ops') + expect(ops.map(r => r.attributes['session.id'])).toEqual(['survivor']) + }) + it('warns instead of throwing when backend shutdown fails', async () => { const backend = new FakeBackend() backend.shutdownError = new Error('exporter unreachable') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e96b9cf4d..7b82b3bf3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3567,6 +3567,9 @@ importers: '@opentelemetry/exporter-logs-otlp-http': specifier: ^0.220.0 version: 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': specifier: ^2.9.0 version: 2.10.0(@opentelemetry/api@1.9.1) From d398eda43222b8a64b3afb733f2e37646ec9300f Mon Sep 17 00:00:00 2001 From: kingwl Date: Sat, 25 Jul 2026 03:48:32 +0800 Subject: [PATCH 05/70] fix(telemetry): join overlapping flush hints; contain adoption replay per event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round, both pinned red-first: - Overlapping turn-boundary flush hints now JOIN the outstanding flush promise (Promise.all) instead of displacing it: the SDK's concurrent-flush guard resolves an overlapping forceFlush() immediately, so retaining only the latest promise let shutdown() proceed while the first export was still in flight — the same silent drop the single-flush fix closed. - Adoption replay contains failures per event, matching the firehose: one rejected record is withheld fail-closed while the rest of the historical log still hands off. Wrapping the whole loop let a single failure silently skip the remainder on an already-adopted session. --- .../session-telemetry-otel/src/index.ts | 12 ++++--- .../session-telemetry-otel/tests/otel.spec.ts | 33 +++++++++++++++++++ .../session-telemetry/src/coordinator.ts | 18 ++++++---- .../session-telemetry/tests/telemetry.spec.ts | 26 +++++++++++++++ 4 files changed, 77 insertions(+), 12 deletions(-) diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index 8dde46828e..d415d1a7e8 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -147,7 +147,7 @@ export class TelemetryOtel extends Telemetry { }) } - /** The latest turn-boundary flush, retained so {@link shutdown} can order behind it. */ + /** Every not-yet-settled turn-boundary flush, retained so {@link shutdown} can order behind ALL of them. */ private inflightFlush: Promise = Promise.resolve() /** Forward the turn-boundary hint to the SDK's flush, fire-and-forget. */ @@ -157,15 +157,17 @@ export class TelemetryOtel extends Telemetry { // this once the fiber is disposed — a rejection would be SDK drift. The // settled promise is retained (not awaited): the SDK's concurrent-flush // guard makes a flush that overlaps another return WITHOUT draining, so - // shutdown must wait this one out before trusting its own flush. + // an overlapping hint resolves instantly and must JOIN the outstanding + // one, not displace it — shutdown orders behind the whole set. /* v8 ignore next -- unreachable guard: forceFlush does not reject while the provider is alive */ - this.inflightFlush = this.provider.forceFlush().catch(() => {}) + const flush = this.provider.forceFlush().catch(() => {}) + this.inflightFlush = Promise.all([this.inflightFlush, flush]).then(() => undefined) } /** * Delegate disposal to the SDK's shutdown contract: flush the queue and - * quiesce. Orders behind the last turn-boundary flush first — shutdown's - * internal flush is a no-op while one is in flight (the SDK's + * quiesce. Orders behind every outstanding turn-boundary flush first — + * shutdown's internal flush is a no-op while one is in flight (the SDK's * concurrent-flush guard), which would silently drop everything enqueued * after that flush snapshot, including the coordinator's dispose-time * `shutdown` markers. Awaited (and error-contained) by the coordinator's diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index b44b42ea04..85bfedeb1d 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -155,6 +155,39 @@ describe('TelemetryOtel wire', () => { expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) }) + it('orders shutdown behind the OLDEST in-flight flush when hints overlap', async () => { + // The SDK's concurrent-flush guard resolves an overlapping forceFlush() + // immediately; if the backend RETAINS only the latest flush promise, two + // back-to-back turn flushes leave shutdown awaiting the instantly-resolved + // second one while the first still exports — reopening the same silent + // drop the single-flush race test pins. + const gate = Promise.withResolvers() + const arrived = Promise.withResolvers() + const { url, captures } = await mockCollector(async (index) => { + if (index === 0) { + arrived.resolve(true) + await gate.promise + } + }) + const { ctx, fiber } = await boot(url) + const session = ctx.sessions.create(SessionId('race2'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + ctx.telemetry.flush!() + await arrived.promise + // Second hint while the first export is held open: resolves immediately + // under the SDK's guard and must not displace the outstanding one. + ctx.telemetry.flush!() + + const disposal = fiber.dispose() + await new Promise(resolve => setTimeout(resolve, 50)) + gate.resolve(true) + await disposal + + const ops = allRecords(captures).filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops') + expect(ops).toHaveLength(1) + expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) + }) + it('passes exporter options beyond url and headers through to the SDK exporter', async () => { const { url, captures } = await mockCollector() const ctx = new Context() diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index e8213e588a..027c6af09e 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -113,15 +113,19 @@ export class TelemetryCoordinator { * @param session - the live session to adopt; a second adoption is a no-op. */ private adopt(session: Session): void { - this.contain(() => { - if (this.adopted.has(session)) return - this.adopted.add(session) - const cursor = handoffCursor.get(session) ?? -1 - for (const event of session.events) { + if (this.adopted.has(session)) return + this.adopted.add(session) + const cursor = handoffCursor.get(session) ?? -1 + // Containment is PER EVENT, matching the firehose: one rejected record + // is withheld fail-closed while the rest of the historical replay + // proceeds — wrapping the whole loop would let a single failure silently + // skip the remainder of the log on an already-adopted session. + for (const event of session.events) { + this.contain(() => { if (event.seq <= cursor) this.track(session, event) else this.capture(session, event) - } - }) + }) + } } /** Feed the chunk projection without handing off — the ≤cursor half of re-adoption. */ diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 55b5ed694a..d52baa91f5 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -26,11 +26,15 @@ class FakeBackend implements TelemetryBackend { records: TelemetryRecord[] = [] calls: string[] = [] emitError: Error | undefined + rejectSeq: number | undefined shutdownError: Error | undefined shutdownResolved = false emit(record: TelemetryRecord): void { if (this.emitError) throw this.emitError + if (this.rejectSeq !== undefined && record.attributes['event.seq'] === this.rejectSeq) { + throw new Error(`backend rejected seq ${this.rejectSeq}`) + } this.records.push(record) this.calls.push(`emit:${String(record.attributes['event.seq'] ?? record.attributes['telemetry.op'])}`) } @@ -213,6 +217,28 @@ describe('TelemetryCoordinator adoption', () => { expect(second.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end']) }) + it('replays past a record the backend rejects: one event withheld, the rest adopted', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const session = liveSession(ctx, 'partial') + appendTurn(session) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // The backend rejects exactly the middle historical event: fail-closed + // must withhold THAT record only — an adoption replay that dies on the + // first contained failure would silently skip the rest of the log while + // the session stays marked adopted. + backend.rejectSeq = 1 + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + expect(backend.ledger().map(r => r.attributes['event.seq'])).toEqual([0, 2]) + expect(warn).toHaveBeenCalled() + }) + it('re-hands the full log when no cursor survived (fresh session object)', async () => { const backend = new FakeBackend() const ctx = new Context() From ec38cac8efb4a95e8cda44839cc23999ce767be8 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sat, 25 Jul 2026 21:21:45 +0800 Subject: [PATCH 06/70] fix(telemetry): emit the shutdown marker at the session's own disposal edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (Codex P1), pinned red-first: the marker was tied to telemetry-plugin lifetime, but receivers key crash detection on its absence per session. A normally closed session in a long-running host retired silently (classified as a crash once stale), while a telemetry reload marked every still-live session as cleanly ended. The session/disposed handler now emits the marker at the session's own termination edge before retiring it; the dispose-time sweep only marks sessions still alive at application teardown (their own edge would fire unobserved). READMEs restate the marker semantics: telemetry stopped observing cleanly — a marker followed by more session events is a telemetry reload, not a session restart. --- .../session-telemetry-otel/README.md | 2 +- .../telemetry/session-telemetry/README.md | 2 +- .../session-telemetry/src/coordinator.ts | 43 +++++++++++++------ .../session-telemetry/tests/telemetry.spec.ts | 36 +++++++++++++--- 4 files changed, 61 insertions(+), 22 deletions(-) diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index bd36c7acb3..04fa229fc5 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -23,7 +23,7 @@ Records carry the complete `event.data` as the seam's `telemetry/redact` waterfa ## Field mapping -Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record staleness (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). +Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record absence (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). The marker means telemetry stopped observing the session cleanly — emitted at the session's own disposal, or at application teardown for sessions still running then; a marker followed by more of that session's events is a telemetry reload, not a session restart. ## Model Experience diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 80667b308f..af816a6158 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -8,7 +8,7 @@ The telemetry seam: the CAPTURE side of session-event reporting, behind a backen ## Capture points -The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection — seed events from fork/resume never re-emit on the firehose), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (retire: release the adopted entry so a long-lived backend neither retains closed sessions nor stamps dispose-time markers for them), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (per still-adopted session emit its `shutdown` operational record, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). +The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection — seed events from fork/resume never re-emit on the firehose), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (mark each session still alive at teardown, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). ## The redact waterfall diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 027c6af09e..d500a66d89 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -35,19 +35,20 @@ const handoffCursor = new WeakMap() * Registers the persistence-coordinator listener set plus the `agent/error` * relay, all through `ctx.effect()`/`ctx.on()` on the composing fiber, and * sweeps already-live sessions (a hot reload does not replay - * `session/created`). A `session/disposed` retires the session from the - * adopted set — a long-lived backend must not retain closed sessions (and - * their frozen event logs) or stamp dispose-time markers for sessions that - * already ended. Disposal emits each still-adopted session's `shutdown` - * operational record and then awaits the backend's `shutdown()`; a failure - * there warns instead of throwing — best-effort reporting must not fail - * application teardown. + * `session/created`). A `session/disposed` emits the session's `shutdown` + * operational record — the marker rides the session's own termination edge, + * where receivers key crash detection — and retires it from the adopted set, + * so a long-lived backend neither retains closed sessions (and their frozen + * event logs) nor re-marks them at unload. Disposal marks the sessions still + * alive at teardown (their own edge would fire unobserved) and then awaits + * the backend's `shutdown()`; a failure there warns instead of throwing — + * best-effort reporting must not fail application teardown. */ export class TelemetryCoordinator { /** - * Sessions adopted by THIS fiber and still live, for dispose-time - * `shutdown` records and double-adoption protection; `session/disposed` - * retires entries. + * Sessions adopted by THIS fiber and still live, for double-adoption + * protection and the teardown sweep of unmarked sessions; + * `session/disposed` marks and retires entries. */ private readonly adopted = new Set() /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */ @@ -64,10 +65,17 @@ export class TelemetryCoordinator { ctx.on('session/created', (session) => { this.adopt(session) }) - // Retirement is observe-only: the projection/cursor WeakMaps die with the - // Session object; only the strong adopted set needs the explicit release. + // The session's own termination edge: emit the shutdown marker HERE — + // receivers classify a session with activity and no marker as crashed, + // so a normally closed session in a long-running host must get its + // marker at disposal, not never. Then retire: the projection/cursor + // WeakMaps die with the Session object; only the strong adopted set + // needs the explicit release. ctx.on('session/disposed', (session) => { - this.adopted.delete(session) + this.contain(() => { + if (!this.adopted.delete(session)) return + this.handOff(shutdownRecord(session)) + }) }) ctx.on('session/event', (session, event) => { this.contain(() => { @@ -87,6 +95,10 @@ export class TelemetryCoordinator { }) }) ctx.effect(() => async () => { + // Sessions still adopted here are alive through a whole-application + // teardown (their own disposal edge will fire after telemetry is gone, + // unobserved) — mark them now so the receiver sees a clean stop of + // observation rather than a crash-shaped silence. for (const session of this.adopted) { this.contain(() => { this.handOff(shutdownRecord(session)) @@ -214,7 +226,10 @@ export class TelemetryCoordinator { } } -/** Build the per-session clean-exit marker emitted at dispose, before the backend's `shutdown()`. */ +/** + * Build the per-session clean-exit marker: emitted at the session's own + * disposal edge, or at coordinator dispose for sessions still alive then. + */ function shutdownRecord(session: Session): TelemetryRecord { return { channel: 'ops', diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index d52baa91f5..efbd878a25 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -275,6 +275,26 @@ describe('TelemetryCoordinator lifecycle and containment', () => { expect(backend.flush).not.toHaveBeenCalled() }) + it('emits no marker for a session whose announcement was vetoed before adoption', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + // A listener registered BEFORE the coordinator vetoes publication: the + // store still emits the paired `session/disposed` for rollback, but the + // coordinator never saw `session/created` — a marker for a session the + // receiver saw no activity from would be noise, not signal. + ctx.on('session/created', () => { + throw new Error('vetoed by an earlier listener') + }) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + expect(() => ctx.sessions.create(SessionId('vetoed'), { meta: {} })).toThrow('vetoed') + expect(backend.records.filter(r => r.channel === 'ops')).toHaveLength(0) + }) + it('emits each adopted session’s shutdown record before awaiting backend shutdown', async () => { const { ctx, backend, fiber } = await setup() liveSession(ctx, 's1') @@ -288,21 +308,25 @@ describe('TelemetryCoordinator lifecycle and containment', () => { expect(ops.every(r => !('event.seq' in r.attributes) && !('event.type' in r.attributes))).toBe(true) }) - it('retires a disposed session: no retention, no stale shutdown marker at unload', async () => { + it('emits the shutdown marker at the session’s own disposal edge, then retires it', async () => { const { ctx, backend, fiber } = await setup() liveSession(ctx, 'survivor') // A session owned by its own fiber: disposing the fiber detaches it from - // the store and emits `session/disposed` — the authoritative retirement - // signal a long-lived telemetry backend must honor, or every closed - // session (and its full event log) stays strongly held for the backend's - // lifetime and final unload emits shutdown markers for dead sessions. + // the store and emits `session/disposed` — the authoritative termination + // edge. The marker must ride THAT edge (receivers classify a session with + // activity and no marker as crashed, so a normally closed session in a + // long-running host must not look like a crash), and the session retires + // from the adopted set so unload neither retains it nor re-marks it. const owner = await ctx.plugin(Object.assign((inner: Context) => { inner.sessions.create(SessionId('ephemeral'), { meta: {} }) }, { inject: ['sessions'] })) await owner.dispose() + const atEdge = backend.records.filter(r => r.channel === 'ops') + expect(atEdge.map(r => r.attributes['session.id'])).toEqual(['ephemeral']) + expect(atEdge[0]!.attributes['telemetry.op']).toBe('shutdown') await fiber.dispose() const ops = backend.records.filter(r => r.channel === 'ops') - expect(ops.map(r => r.attributes['session.id'])).toEqual(['survivor']) + expect(ops.map(r => r.attributes['session.id'])).toEqual(['ephemeral', 'survivor']) }) it('warns instead of throwing when backend shutdown fails', async () => { From 0d6bfd88563fea212a08c9d94dc3690541c8f498 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:59:01 +0800 Subject: [PATCH 07/70] refactor(process): split the process manager out of the bash executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-06-timeout-deadline-library.md | 2 +- .../2026-07-06-timeout-deadline-library.zh.md | 2 +- .../2026-07-26-process-manager-seam.md | 38 +++++ apps/cli/cordis.yml | 4 + apps/cli/package.json | 1 + docs/capability-seams.md | 12 +- docs/config-catalog.md | 8 +- docs/cordis-catalog/services.md | 27 +++- docs/core-data-structures/bash.md | 28 +--- docs/core-data-structures/bash.zh.md | 28 +--- docs/core-data-structures/process.md | 133 +++++++++++++++++ docs/module-graph.md | 15 +- examples/acp-agent/composition.md | 3 + examples/acp-agent/cordis.yml | 4 + examples/cordis-agent/composition.md | 3 + examples/cordis-agent/cordis.yml | 4 + examples/headless-agent/composition.md | 3 + examples/headless-agent/cordis.yml | 4 + .../semantic-checkpoint.cordis.snapshot.yml | 4 + .../headless-agent/tests/code-mode.e2e.ts | 3 + .../tests/fixtures/goal-domain/cordis.yml | 4 + .../tests/fixtures/time-context.cordis.yml | 4 + examples/headless-agent/tests/harness.ts | 2 + examples/jsonrpc-agent/cordis.yml | 4 + examples/package.json | 1 + examples/tui-agent/composition.md | 3 + examples/tui-agent/cordis.yml | 4 + .../tests/fixtures/tui-scripted.cordis.yml | 4 + examples/tui-agent/tests/tui.snapshot.ts | 2 + packages/README.md | 1 + packages/bash/README.md | 4 +- packages/bash/bash-local/README.md | 21 ++- packages/bash/bash-local/package.json | 3 + packages/bash/bash-local/src/index.ts | 117 ++++++++------- .../bash/bash-local/tests/executor.spec.ts | 50 ++++--- packages/bash/bash-local/tsconfig.json | 3 + packages/bash/bash-sandbox/package.json | 1 + packages/bash/bash-sandbox/src/index.ts | 4 +- .../bash/bash-sandbox/tests/sandbox.spec.ts | 4 +- packages/bash/bash/package.json | 2 + packages/bash/bash/src/types.ts | 26 +--- packages/bash/bash/tsconfig.json | 3 + packages/bash/tool-bash/package.json | 1 + .../bash/tool-bash/tests/integration.spec.ts | 2 + packages/bash/tool-bash/tests/tools.spec.ts | 14 +- .../cordis/tool-cordis/src/api-catalog.ts | 30 ++++ .../examples/acp-demo/tests/built-bin.e2e.ts | 2 + .../examples/acp-demo/tests/load-path.e2e.ts | 2 + .../examples/cli-demo/tests/built-bin.e2e.ts | 2 + packages/fs/tool-fs-search/package.json | 1 + .../tool-fs-search/tests/integration.spec.ts | 2 + packages/hooks/hooks-claude/package.json | 1 + .../hooks/hooks-claude/tests/bridge.spec.ts | 4 + .../hooks-claude/tests/coverage-cases.ts | 5 + packages/hooks/hooks-codex/package.json | 1 + .../hooks/hooks-codex/tests/bridge.spec.ts | 4 + .../hooks/hooks-codex/tests/coverage-cases.ts | 4 + packages/process/README.md | 10 ++ packages/process/process-local/README.md | 27 ++++ packages/process/process-local/package.json | 39 +++++ packages/process/process-local/src/index.ts | 53 +++++++ .../process/process-local/src/invariant.ts | 30 ++++ .../process-local/src/spawn.ts} | 140 ++++-------------- .../process-local/tests/manager.spec.ts | 70 +++++++++ .../process-local/tests/spawn.spec.ts} | 124 +++++++++------- packages/process/process-local/tsconfig.json | 24 +++ packages/process/process/README.md | 26 ++++ packages/process/process/package.json | 37 +++++ packages/process/process/src/index.ts | 62 ++++++++ packages/process/process/src/invariant.ts | 22 +++ packages/process/process/src/types.ts | 128 ++++++++++++++++ .../process/process/tests/service.spec.ts | 55 +++++++ packages/process/process/tsconfig.json | 21 +++ .../sdk/helper/src/features/builtin/index.ts | 5 +- packages/subagent/subagent-spawn/package.json | 1 + .../subagent/subagent-spawn/tests/harness.ts | 2 + pnpm-lock.yaml | 60 ++++++++ python/sdk-runtime/package.json | 2 + .../runtime/cordis.yml | 4 + python/sdk/tests/test_bundled_runtime.py | 2 + scripts/doc-budgets.manifest.json | 2 +- scripts/gen-cordis-catalog.ts | 7 +- scripts/gen-doc-graphs.ts | 10 ++ scripts/gen-tool-catalog.ts | 2 + scripts/type-equiv.manifest.json | 55 ++++--- .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 2 + tsconfig.host.json | 2 + vitest.config.ts | 2 +- 89 files changed, 1331 insertions(+), 365 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md create mode 100644 docs/core-data-structures/process.md create mode 100644 packages/process/README.md create mode 100644 packages/process/process-local/README.md create mode 100644 packages/process/process-local/package.json create mode 100644 packages/process/process-local/src/index.ts create mode 100644 packages/process/process-local/src/invariant.ts rename packages/{bash/bash-local/src/run.ts => process/process-local/src/spawn.ts} (66%) create mode 100644 packages/process/process-local/tests/manager.spec.ts rename packages/{bash/bash-local/tests/run.spec.ts => process/process-local/tests/spawn.spec.ts} (78%) create mode 100644 packages/process/process-local/tsconfig.json create mode 100644 packages/process/process/README.md create mode 100644 packages/process/process/package.json create mode 100644 packages/process/process/src/index.ts create mode 100644 packages/process/process/src/invariant.ts create mode 100644 packages/process/process/src/types.ts create mode 100644 packages/process/process/tests/service.spec.ts create mode 100644 packages/process/process/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index 11d4b8cd48..b459a7479e 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -8,7 +8,7 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md) Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden. -- **bash** ([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts)) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. +- **bash** ([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts)) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. - **web_fetch** ([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`. - **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.) diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index 334914c689..e79ac6d7b6 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -8,7 +8,7 @@ Status: implemented 超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。 -- **bash**([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。 +- **bash**([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。 - **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。 - **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。) diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md new file mode 100644 index 0000000000..2157259517 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md @@ -0,0 +1,38 @@ +# Agent Note: The process manager is its own seam under the bash executors (`dsh-process` / `dsh-process-local`) + +Status: implemented + +English | [中文](2026-07-26-process-manager-seam.zh.md) + +## 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](2026-07-26-task-registry-seam.md), whose registrations deliberately outlive producer fibers. + +## Decision + +A new `process/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it: + +- **`@deepseek-ai/dsh-process` (interface)** — the abstract `ProcessManager` owning `ctx.processes` with one method, `spawn(spec): ProcessHandle`, and the shared vocabulary: the fully-explicit `ProcessSpawnSpec` (argv, cwd, per-stream caps, spill cap, 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), `ProcessHandle` with non-consuming offset-based readers, `ProcessOutcome` with deliberately no timeout/cancel classification, and the `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted. +- **`@deepseek-ai/dsh-process-local` (implementation)** — `LocalProcessManager` over the former `run.ts` plumbing (`spawn.ts`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the two-channel `DSH_*` 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 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. +- **`dsh-bash-local` (consumer)** — `inject: ['processes']`; maps each resolved `BashExecSpec` onto a `ProcessSpawnSpec` (`['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-process`, so no bash consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned. + +Every composition that loads a bash executor now also loads `@deepseek-ai/dsh-process-local` (CLI, examples, python bundled runtime, create-sdk's bash feature resources, inline test configs). + +Background-process lifetime moved from the executor to the manager: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the manager'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 manager 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](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.processes` in the same change.** Rejected as scope creep with real design risk: 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 ships proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule; the others are named as deferred work in the seam README. + +**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 manager.** Rejected: a generic process manager 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-process-local` (argv-based, plus argv-validation and manager lifecycle/disposal suites); the executor suite now pins the bash-owned layers (classification, merge, spawn-failure note, manager-owned lifetime) against the real manager. + +Cost: one more package pair and one more composition row everywhere a bash executor loads — a boot that loads an executor without the manager leaves `ctx.bash` pending on `ctx.processes` (standard missing-service behavior). The moved-vocabulary re-exports keep `dsh-bash` imports working but mean two packages now name the same types; the process 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. diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 1d5c37cff5..5521c09b65 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -88,6 +88,10 @@ - id: workspace name: '@deepseek-ai/dsh-workspace' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash-local name: '@deepseek-ai/dsh-bash-local' diff --git a/apps/cli/package.json b/apps/cli/package.json index 4f3bb5be35..7ef006c924 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2da9652c59..0fef36c63e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -82,10 +82,13 @@ flowchart LR pkg_agent_spine_demo["agent-spine-demo"] pkg_goal["goal"] svc_goals["ctx.goals
Same-session goal domain"] - pkg_bash["bash"] - svc_bash["ctx.bash
Bash executor seam"] + pkg_process["process"] + svc_processes["ctx.processes
Process manager seam"] + pkg_process_local["process-local"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] + pkg_bash["bash"] + svc_bash["ctx.bash
Bash executor seam"] svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] pkg_pty["pty"] svc_pty["ctx.pty
Persistent PTY session registry"] @@ -164,6 +167,8 @@ flowchart LR pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode + pkg_process --> svc_processes + pkg_process_local --> svc_processes pkg_pty --> svc_pty pkg_pty_local --> svc_pty pkg_sandbox --> svc_sandbox @@ -234,6 +239,8 @@ flowchart LR svc_invariants --> pkg_session svc_llm --> pkg_agent_loop svc_llm --> pkg_compact_basic + svc_processes --> pkg_bash_local + svc_processes --> pkg_bash_sandbox svc_pty --> pkg_tool_pty svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_pty_local @@ -317,6 +324,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | +| `ctx.processes` | `seam` | [`process`](../packages/process/process) | [`process-local`](../packages/process/process-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | - | The bash executors spawn their process groups through ctx.processes; the manager owns group lifetime, bounded spill-backed output, and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d794425e10..322f5c70d3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -192,6 +192,8 @@ Source: [`packages/examples/agent-spine-demo/src/index.ts:87`](../packages/examp ## `@deepseek-ai/dsh-bash-local` +Requires: `processes` + ```ts config-catalog /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { @@ -210,11 +212,11 @@ export interface Config { } ``` -Source: [`packages/bash/bash-local/src/index.ts:17`](../packages/bash/bash-local/src/index.ts) +Source: [`packages/bash/bash-local/src/index.ts:39`](../packages/bash/bash-local/src/index.ts) ## `@deepseek-ai/dsh-bash-sandbox` -Requires: `sandbox` · `sandboxPolicy` +Requires: `processes` · `sandbox` · `sandboxPolicy` ```ts config-catalog /** @@ -2053,6 +2055,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) +- `@deepseek-ai/dsh-process-local` ([`packages/process/process-local/src/index.ts`](../packages/process/process-local/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) @@ -2073,6 +2076,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) +- `@deepseek-ai/dsh-process` — abstract `ProcessManager` ([`packages/process/process/src/index.ts`](../packages/process/process/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e14d28564a..6ea9919713 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -315,7 +315,7 @@ collect(execution: ToolExecution): DshEnvironment list(): BashEnvVariableInfo[] ``` -Types: [DshEnvironment](../core-data-structures/bash.md) · [ToolExecution](../core-data-structures/tools.md) +Types: [DshEnvironment](../core-data-structures/process.md) · [ToolExecution](../core-data-structures/tools.md) Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts) @@ -829,6 +829,31 @@ Types: [Agent](../core-data-structures/core.md) Source: [`packages/plan/plan-mode/src/index.ts:141`](../../packages/plan/plan-mode/src/index.ts) +## `ctx.processes` — `ProcessManager` (abstract seam) + +Abstract process manager. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.processes` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). + +Implementations must honor these semantics: + +- spawn returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures. +- Output readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. +- ProcessHandle.kill and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole process group. +- Disposal kills all still-running managed processes and awaits their exit. + +```ts cordis-catalog +/** + * Start one managed child process from a fully-specified spec; this seam + * applies no defaults. + * @param spec - argv, directory, limits, grace, cancellation, and environment. + * @returns the live process handle (readers, kill, outcome promise). + */ +abstract spawn(spec: ProcessSpawnSpec): ProcessHandle +``` + +Types: [ProcessHandle](../core-data-structures/process.md) · [ProcessSpawnSpec](../core-data-structures/process.md) + +Source: [`packages/process/process/src/index.ts:48`](../../packages/process/process/src/index.ts) + ## `ctx.pty` — `PtyService` In-process registry for replaceable PTY backends and exact-Agent sessions. diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 35cf206158..e092bfb1bf 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -2,23 +2,13 @@ English | [中文](bash.zh.md) -The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle. +The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle. Raw process-group mechanics live behind the [process-manager seam](process.md). Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) ## Managed shell environment namespace -`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; executors remove inherited `DSH_*` names before merging the current snapshot. - -```ts type-equiv -/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ -type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` -``` - -```ts type-equiv -/** Trusted DeepSeek Harness variables for one bash execution. */ -type DshEnvironment = Readonly> -``` +`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; the process manager removes inherited `DSH_*` names before merging the current snapshot. The `DshEnvironmentKey`/`DshEnvironment` vocabulary is owned by the [process-manager seam](process.md) and re-exported by `dsh-bash`. ## Request vs. spec: the `resolve()` split @@ -145,19 +135,7 @@ interface BashRunResult { } ``` -Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info. When truncated, `text` is the **tail** and the complete stream spills to a private file: - -```ts type-equiv -/** One captured stream: the (possibly truncated) text plus recovery info. */ -interface CollectedOutput { - /** Collected text — the TAIL of the stream when truncated. */ - text: string - /** True when bytes were dropped from `text`. */ - truncated: boolean - /** Path to a file holding the COMPLETE stream, when truncated and available. */ - spillPath?: string -} -``` +Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The shape is owned by the [process-manager seam](process.md) and re-exported by `dsh-bash`. ## File sandbox: `BashSandboxInfo` diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index 0cfeb9e1a8..7ae60b9f38 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -2,23 +2,13 @@ [English](bash.md) | 中文 -bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。 +bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制位于[进程管理器 seam](process.md)之后。 源码:[`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) ## 受管 shell 环境命名空间 -`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;执行器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。 - -```ts type-equiv -/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ -type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` -``` - -```ts type-equiv -/** Trusted DeepSeek Harness variables for one bash execution. */ -type DshEnvironment = Readonly> -``` +`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;进程管理器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。`DshEnvironmentKey`/`DshEnvironment` 词汇归[进程管理器 seam](process.md)所有,由 `dsh-bash` 重导出。 ## 请求与规格:`resolve()` 拆分 @@ -145,19 +135,7 @@ interface BashRunResult { } ``` -每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息。截断时,`text` 是**尾部**,完整流溢出到一个私有文件: - -```ts type-equiv -/** One captured stream: the (possibly truncated) text plus recovery info. */ -interface CollectedOutput { - /** Collected text — the TAIL of the stream when truncated. */ - text: string - /** True when bytes were dropped from `text`. */ - truncated: boolean - /** Path to a file holding the COMPLETE stream, when truncated and available. */ - spillPath?: string -} -``` +每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。该形状归[进程管理器 seam](process.md)所有,由 `dsh-bash` 重导出。 ## 文件沙箱:`BashSandboxInfo` diff --git a/docs/core-data-structures/process.md b/docs/core-data-structures/process.md new file mode 100644 index 0000000000..93e7a73ad6 --- /dev/null +++ b/docs/core-data-structures/process.md @@ -0,0 +1,133 @@ +# Process Manager + +The child-process manager seam is split across interface ([dsh-process](../../packages/process/process), `ctx.processes`) and implementation ([dsh-process-local](../../packages/process/process-local)); its consumers are other capability seams — today the [bash executor family](bash.md), which passes `['bash', '-c', command]` argv and owns every default. This seam owns the managed `DSH_*` environment namespace and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports them so bash consumers keep one import root. + +Source: [`packages/process/process/src/types.ts`](../../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. + +```ts type-equiv +/** + * 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 | 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. + +```ts type-equiv +/** + * 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 + /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ + kill(): void +} +``` + +```ts type-equiv +/** + * 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 +} +``` + +```ts type-equiv +/** 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). + +```ts type-equiv +/** + * 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`](../../packages/process/process/src/index.ts) seam defines `spawn` only; [`LocalProcessManager`](../../packages/process/process-local/src/index.ts) is the local implementation (detached groups, tail-keep spill-backed collection, credential scrub, kill-and-join disposal). See [`dsh-process`](../../packages/process/process/README.md) for the seam contract and [`dsh-process-local`](../../packages/process/process-local/README.md) for the mechanics. diff --git a/docs/module-graph.md b/docs/module-graph.md index 87e0f7c0d5..10e6ab65a6 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -184,6 +184,10 @@ flowchart TD subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end + subgraph group_process["packages/process"] + pkg_process["process"] + pkg_process_local["process-local"] + end subgraph group_pty["packages/pty"] pkg_pty["pty"] pkg_pty_local["pty-local"] @@ -243,6 +247,7 @@ flowchart TD pkg_jsonrpc_demo --> pkg_invariants pkg_host_apiproxy --> pkg_invariants pkg_host_webserver --> pkg_invariants + pkg_process --> pkg_invariants pkg_storage --> pkg_invariants pkg_llm --> pkg_brand pkg_llm --> pkg_invariants @@ -266,6 +271,8 @@ flowchart TD pkg_client_ui_workspace --> pkg_client_ui_primitives pkg_client_ui_workspace --> pkg_client_ui_slots pkg_client_ui_workspace --> pkg_invariants + pkg_process_local --> pkg_invariants + pkg_process_local --> pkg_process pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_telemetry --> pkg_brand @@ -307,6 +314,7 @@ flowchart TD pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt pkg_bash --> pkg_invariants + pkg_bash --> pkg_process pkg_bash --> pkg_sandbox pkg_fs --> pkg_brand pkg_fs --> pkg_invariants @@ -371,6 +379,7 @@ flowchart TD pkg_goal --> pkg_session pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_process pkg_bash_local --> pkg_timeout pkg_fs_local --> pkg_fs pkg_fs_local --> pkg_invariants @@ -828,6 +837,7 @@ flowchart TD | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | +| [`process`](../packages/process/process) | `process` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | @@ -836,6 +846,7 @@ flowchart TD | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`process-local`](../packages/process/process-local) | `process` | [`invariants`](../packages/support/invariants), [`process`](../packages/process/process) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -850,7 +861,7 @@ flowchart TD | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox) | +| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`process`](../packages/process/process), [`sandbox`](../packages/sandbox/sandbox) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -869,7 +880,7 @@ flowchart TD | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | -| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`process`](../packages/process/process), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 8d112f2910..e4660ed8da 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -14,6 +14,8 @@ flowchart LR cfg --> plugin_acp_sandbox plugin_acp_sandbox_policy["sandbox-policy
@deepseek-ai/dsh-sandbox-policy"] cfg --> plugin_acp_sandbox_policy + plugin_acp_processes["processes
@deepseek-ai/dsh-process-local"] + cfg --> plugin_acp_processes plugin_acp_bash["bash
@deepseek-ai/dsh-bash-sandbox"] cfg --> plugin_acp_bash plugin_acp_approval["approval
@deepseek-ai/dsh-user-approval"] @@ -68,6 +70,7 @@ flowchart LR | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `sandbox` | `@deepseek-ai/dsh-sandbox-local` | | `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` | +| `processes` | `@deepseek-ai/dsh-process-local` | | `bash` | `@deepseek-ai/dsh-bash-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `acp-agent` | `@deepseek-ai/dsh-acp-demo` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index fa7f6ad4ef..38994b1826 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -33,6 +33,10 @@ mode: !!js "process.env.DSH_PERMISSION_MODE ?? (process.env.DSH_SNAPSHOT === undefined ? 'workspace-write' : 'danger-full-access')" workspaceRoot: !!js process.cwd() +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-sandbox' config: diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 3cbcb0e571..78eb186e37 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_cordis_hmr plugin_cordis_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_cordis_llm_deepseek + plugin_cordis_processes["processes
@deepseek-ai/dsh-process-local"] + cfg --> plugin_cordis_processes plugin_cordis_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_cordis_bash plugin_cordis_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] @@ -39,6 +41,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `processes` | `@deepseek-ai/dsh-process-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `web` | `@deepseek-ai/dsh-web` | diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 6144d3ae4d..051c3a5662 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -25,6 +25,10 @@ # Local bash executor for agent-spine-demo's tool-bash schema — gives the agent an # ordinary tool whose calls make the mounted listeners observably fire. +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index e467d32833..98ef6cd167 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -10,6 +10,8 @@ flowchart LR cfg["examples/headless-agent
cordis.yml"] plugin_headless_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_headless_llm_deepseek + plugin_headless_processes["processes
@deepseek-ai/dsh-process-local"] + cfg --> plugin_headless_processes plugin_headless_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_headless_bash plugin_headless_cli_agent["cli-agent
@deepseek-ai/dsh-cli-demo"] @@ -54,6 +56,7 @@ flowchart LR | Plugin id | Package / module | | --- | --- | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `processes` | `@deepseek-ai/dsh-process-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `cli-agent` | `@deepseek-ai/dsh-cli-demo` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 944fe7e180..261eaa6d20 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -19,6 +19,10 @@ - id: deepseek-v4-flash contextWindow: 128000 +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml index fc7959e25d..66fbca2e6a 100644 --- a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml +++ b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml @@ -17,6 +17,10 @@ file: !!js process.env.DSH_SNAPSHOT_FILE overrideFile: !!js process.env.DSH_SNAPSHOT_OVERRIDE +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index bb8fdfe260..6f36b1f8c9 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -13,6 +13,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' @@ -55,6 +56,7 @@ async function codeModeHarness(cwd: string): Promise { await harness.plugin(AgentRegistry) await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek) + await harness.plugin(LocalProcessManager) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) await harness.plugin(WorkerCodeRuntime, {}) @@ -114,6 +116,7 @@ async function backgroundCodeModeHarness(cwd: string): Promise { const harness = await typedCodeModeHarness() await harness.plugin(LocalTaskService) await harness.plugin(ToolTasks, {}) + await harness.plugin(LocalProcessManager) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) return harness diff --git a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml index bc9b71685e..d20c05b785 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml +++ b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml @@ -2,6 +2,10 @@ - id: cli-mock-llm name: '../cli-mock-llm.ts' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/fixtures/time-context.cordis.yml b/examples/headless-agent/tests/fixtures/time-context.cordis.yml index 91ba8a1254..1050d32376 100644 --- a/examples/headless-agent/tests/fixtures/time-context.cordis.yml +++ b/examples/headless-agent/tests/fixtures/time-context.cordis.yml @@ -2,6 +2,10 @@ - id: time-context-mock-llm name: './time-context-mock-llm.ts' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index e1edc1dadd..cf5f30aaee 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -59,6 +60,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio await ctx.plugin(LlmDeepSeek, options.modelContextWindow === undefined ? {} : { models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }], }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index f0eb7ea6d8..7576b06cbf 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -17,6 +17,10 @@ thinking: enabled reasoningEffort: max +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/package.json b/examples/package.json index 8a81d399b8..183aa03111 100644 --- a/examples/package.json +++ b/examples/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-lsp": "workspace:*", "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", + "@deepseek-ai/dsh-process-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index fd6d163952..9c64a95fbb 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_tui_hmr plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek + plugin_tui_processes["processes
@deepseek-ai/dsh-process-local"] + cfg --> plugin_tui_processes plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_tui_bash plugin_tui_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] @@ -69,6 +71,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `processes` | `@deepseek-ai/dsh-process-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index e96ef680b2..9a67ce4f77 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -21,6 +21,10 @@ reasoningEffort: max # Local executor for the app bundle's bash tool. +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index 307c5a1dfe..f25cafc64b 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -4,6 +4,10 @@ - id: scripted-llm name: './tui-scripted-llm.ts' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 26ba64f23b..795f21a794 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -8,6 +8,7 @@ import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' import CommandService from '@deepseek-ai/dsh-commands' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -204,6 +205,7 @@ async function mountScenarioContext( skills: { local: { agentsHome: join(cwd, '.agents') } }, }) await ctx.plugin(TokenMeterService) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' }) await ctx.plugin(FsPolicy) diff --git a/packages/README.md b/packages/README.md index cffc4a0554..f9707a178d 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,6 +11,7 @@ Packages live at `packages///`; groups are containers, while names r | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | | [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | +| [`process/`](process/README.md) | Child-process manager capability family: spawn seam + local process-group implementation | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface | | [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface | diff --git a/packages/bash/README.md b/packages/bash/README.md index 2e2bb5692a..57b0ffcf9e 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -4,8 +4,8 @@ The canonical three-package capability seam (see [capability seams](../../.agent | Package | Role | ctx key | |---|---|---| -| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary) | `ctx.bash` | -| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | +| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`process/`](../process/README.md) seam) | `ctx.bash` | +| `bash-local/` | Local `BashExecutor` implementation over the [`process/`](../process/README.md) manager (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) | | `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) | | `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) | diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 5d99161fc0..5fb4bd0a39 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-bash-local -Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c ` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. +Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-process`](../../process/process/README.md) manager: `LocalBashExecutor` spawns `bash -c ` per call as a managed process group through `ctx.processes`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the process manager's. -The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package. +The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`. ## Config @@ -22,11 +22,11 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices: -- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. -- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). After the main shell exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the command open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. -- **Tail-keep truncation + bounded spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. A stream larger than `maxSpillBytes` discards its now-incomplete spill and returns only the marked truncated tail. If the final spill close reports a delayed writeback failure, the executor likewise withholds the path rather than advertising an incomplete file. -- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). -- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. +- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the manager explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-process-local`](../../process/process-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). +- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the manager's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the manager's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the manager, so it survives executor reloads and dies (killed and joined) with the manager's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. ## Model Experience @@ -40,8 +40,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`. - **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them. -- **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported. -- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. -- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. +- **POSIX-only** — the `bash` binary is hardcoded, and the underlying manager's group semantics are POSIX; Windows is unsupported. +- **A background spawn-failure note is single-delivery** — the manager buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. -The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring. +Scrub-heuristic and spill-retention caveats live with [`dsh-process-local`](../../process/process-local/README.md), which owns those mechanics. diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index 152c2db28b..edfcafabe5 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-process": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -38,6 +39,8 @@ "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-process": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 2c25701fb9..5cb99b3caa 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -1,7 +1,10 @@ /** - * Local-subprocess implementation of the bash executor seam. Each command runs - * as `bash -c` in its own process group; disposal kills and joins live groups. - * Execution policy belongs in `tools/pre-execute` or a sandboxing executor. + * Local implementation of the bash executor seam over the process-manager + * seam. Each command runs as `bash -c` in a managed process group spawned + * through `ctx.processes`; this executor owns command defaulting, deadlines + * and cause classification, the model-friendly terminal environment, and the + * model-facing stdout/stderr merge for background reads. Execution policy + * belongs in `tools/pre-execute` or a sandboxing executor. * @module @deepseek-ai/dsh-bash-local */ @@ -9,9 +12,28 @@ import { Context } from 'cordis' import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' +import type { ProcessSpawnSpec } from '@deepseek-ai/dsh-process' import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts' -import type { RunInternals, RunningBash } from './run.ts' + +/** + * Model-friendly environment overrides: disable colors, pagers, and + * interactive terminal features that would garble tool output (the same set + * Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy — + * merged into the ordinary env channel, so a trusted caller's own entry still + * wins; the process manager applies its credential scrub independently. + */ +export const ENV_OVERRIDES = { + NO_COLOR: '1', + TERM: 'dumb', + PAGER: 'cat', + GIT_PAGER: 'cat', +} as const + +/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ +const DEFAULT_GRACE_MS = 3_000 + +/** Default per-stream spill cap (the `maxSpillBytes` config). */ +const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { @@ -39,10 +61,15 @@ function assertPositiveFinite(name: string, value: number): void { } /** - * Local bash executor with bounded output, spill files, and process-group - * `SIGTERM` to `SIGKILL` escalation. + * Local bash executor over `ctx.processes`. Bounded output, spill files, and + * process-group SIGTERM→SIGKILL escalation are the process manager's + * mechanics; this executor supplies their configured budgets per spawn, so a + * still-running background process stays managed (killed and joined at + * composition teardown) even across an executor reload. */ export class LocalBashExecutor extends BashExecutor { + static inject = ['processes'] + static Config: z = z.object({ cwd: z.string(), timeoutMs: z.number().default(120_000), @@ -52,11 +79,6 @@ export class LocalBashExecutor extends BashExecutor { graceMs: z.number().default(DEFAULT_GRACE_MS), }) - /** Live processes retained only so disposal can kill and join them. */ - private live = new Map() - /** Test seam: spill knobs forwarded to runBash. */ - internals: RunInternals = {} - /** Validated config (schemastery applied the defaults before construction). */ readonly config: ResolvedConfig @@ -69,17 +91,6 @@ export class LocalBashExecutor extends BashExecutor { assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) assertPositiveFinite('graceMs', this.config.graceMs) - ctx.effect(() => async () => { - // Await closure so even a TERM-trapping child cannot outlive the fiber. - const pending: Promise[] = [] - for (const [proc, running] of this.live) { - proc.status = 'killed' - running.kill() - pending.push(proc.done) - } - this.live.clear() - await Promise.all(pending) - }, 'local bash teardown') } /** @@ -105,7 +116,7 @@ export class LocalBashExecutor extends BashExecutor { stdoutMaxBytes, ...request.signal ? { signal: request.signal } : {}, // Carry stdin/ordinary env/trusted dshEnv through verbatim — optional, - // no config default. run.ts owns the scrub and merge order. + // no config default. The process manager owns the scrub and merge order. ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, @@ -116,21 +127,27 @@ export class LocalBashExecutor extends BashExecutor { } } - async run(spec: BashExecSpec): Promise { - // One deadline combines timeout and upstream cancellation; disposal clears its timer. - using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') - const outcome = await runBash({ - command: spec.command, + /** Map one resolved bash spec onto a fully-specified process spawn. */ + // XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. + private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): ProcessSpawnSpec { + return { + argv: ['bash', '-c', spec.command], cwd: spec.workdir, - stdoutMaxBytes: spec.stdoutMaxBytes, + stdoutMaxBytes, stderrMaxBytes: this.config.maxOutputBytes, maxSpillBytes: this.config.maxSpillBytes, graceMs: this.config.graceMs, - signal: d.signal, + signal, stdin: spec.stdin, - env: spec.env, + env: { ...ENV_OVERRIDES, ...spec.env }, dshEnv: spec.dshEnv, - }, this.internals).done + } + } + + async run(spec: BashExecSpec): Promise { + // One deadline combines timeout and upstream cancellation; disposal clears its timer. + using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') + const outcome = await this.ctx.processes.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)).done // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined const aborted = d.signal.aborted && !timedOut @@ -139,18 +156,16 @@ export class LocalBashExecutor extends BashExecutor { start(spec: BashExecSpec): BashProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. - const running = runBash({ - command: spec.command, - cwd: spec.workdir, - stdoutMaxBytes: this.config.maxOutputBytes, - stderrMaxBytes: this.config.maxOutputBytes, - maxSpillBytes: this.config.maxSpillBytes, - graceMs: this.config.graceMs, - signal: spec.signal, - stdin: spec.stdin, - env: spec.env, - dshEnv: spec.dshEnv, - }, this.internals) + const running = this.ctx.processes.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + + // A spawn failure produces no process output, so the manager has nothing + // to buffer; the note is delivered exactly once through the read path. + let spawnFailureNote: string | undefined + const consumeSpawnFailure = (): string => { + const note = spawnFailureNote ?? '' + spawnFailureNote = undefined + return note + } let stdoutOffset = 0 let stderrOffset = 0 @@ -166,13 +181,11 @@ export class LocalBashExecutor extends BashExecutor { proc.exitCode = outcome.exitCode proc.signal = outcome.signal this.onProcessDone(proc, running.stderr.readFrom(0).text) - this.live.delete(proc) }, (error: unknown) => { // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' - running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`)) - this.onProcessDone(proc, running.stderr.readFrom(0).text) - this.live.delete(proc) + spawnFailureNote = `spawn failed: ${String(error)}` + this.onProcessDone(proc, spawnFailureNote) }), readOutput: (): BashProcessRead => { const out = running.stdout.readFrom(stdoutOffset) @@ -180,11 +193,14 @@ export class LocalBashExecutor extends BashExecutor { stdoutOffset = out.nextOffset stderrOffset = err.nextOffset + // A failed spawn never produced process output, so the note and real + // stderr text are mutually exclusive. + const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() // Single newline between sections: stdout chunks usually end with one // already; add it only when missing. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' const delta = out.text - + (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '') + + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '') return { delta, lossy: out.lossy || err.lossy, @@ -199,7 +215,6 @@ export class LocalBashExecutor extends BashExecutor { return true }, } - this.live.set(proc, running) return proc } diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 2e24addb3b..aa5eb2fb92 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -4,16 +4,18 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import type { BashProcess } from '@deepseek-ai/dsh-bash' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) async function setup(config: ConstructorParameters[1] = {}) { const ctx = new Context() + await ctx.plugin(LocalProcessManager) + ;(ctx.processes as LocalProcessManager).internals = { spillDir } // A short kill grace via the REAL config path, so escalation tests stay fast. await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir } return { ctx, bash } } @@ -293,44 +295,52 @@ describe('LocalBashExecutor.start (background process handles)', () => { }) }) -describe('LocalBashExecutor disposal', () => { - it('disposing the fiber kills running processes and AWAITS their exit (no orphans, SIGKILL escalation included)', async () => { +describe('process lifecycle ownership (the manager, not the executor)', () => { + it('a background process survives executor-fiber disposal and dies with the process manager', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) + const managerFiber = await ctx.plugin(LocalProcessManager) + ;(ctx.processes as LocalProcessManager).internals = { spillDir } + const executorFiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir } // The child prints its own pid ($$ = the detached bash group leader) so // the test can probe liveness through the public read surface alone. - const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo $$; sleep 60' })) + const proc = bash.start(bash.resolve({ command: 'echo $$; sleep 60' })) const pid = Number((await readUntil(proc, '\n')).trim()) expect(Number.isInteger(pid) && pid > 0).toBe(true) - await fiber.dispose() - // Disposal itself waited: the pid must already be gone, no grace left — - // even for a TERM-trapping child held until the SIGKILL escalation landed. + // Reloading/disposing the executor no longer kills backend work — the + // handle stays live and readable, mirroring the task runtime's + // registrations-outlive-producer-fibers contract. + await executorFiber.dispose() + expect(proc.status).toBe('running') + expect(() => process.kill(pid, 0)).not.toThrow() + + // Manager disposal kills the group and AWAITS its exit (no orphans). + await managerFiber.dispose() expect(() => process.kill(pid, 0)).toThrow() - expect(proc.status).toBe('killed') await proc.done + expect(proc.status).toBe('killed') }) - it('settled processes already left the live map: dispose does not touch them', async () => { + it('manager disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) + const managerFiber = await ctx.plugin(LocalProcessManager) + ;(ctx.processes as LocalProcessManager).internals = { spillDir } + await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir } const finished = bash.start(bash.resolve({ command: 'echo done' })) await finished.done expect(finished.status).toBe('completed') - const running = bash.start(bash.resolve({ command: 'sleep 60' })) + const trapping = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' })) + await readUntil(trapping, 'armed') - await fiber.dispose() - // The teardown marks every LIVE entry killed; a settled process had - // already left the map, so its status stays completed. + await managerFiber.dispose() + // A settled process was untouched; the live one died by escalation. expect(finished.status).toBe('completed') - expect(running.status).toBe('killed') - await running.done - expect(running.signal).toBe('SIGTERM') + await trapping.done + expect(trapping.status).toBe('killed') + expect(trapping.signal).toBe('SIGKILL') }) }) diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json index a55c76f00a..ce24865d49 100644 --- a/packages/bash/bash-local/tsconfig.json +++ b/packages/bash/bash-local/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../bash/bash" }, + { + "path": "../../process/process" + }, { "path": "../../support/invariants" } diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index 9faf613c71..b8737e0e68 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index b889692f3c..3f0fc0c55d 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -34,7 +34,7 @@ export type Config = LocalConfig * mode; `result.sandbox` reports the mode and enforcement actually used. */ export class SandboxBashExecutor extends LocalBashExecutor { - static inject = ['sandbox', 'sandboxPolicy'] + static override inject = ['processes', 'sandbox', 'sandboxPolicy'] // No own Config: the sandbox default (mode + workspaceRoot) moved to // ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config @@ -128,7 +128,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { * Wrap one shell command via the `ctx.sandbox` provider: hand over the * exact `['bash', '-c', command]` argv this executor would spawn, get back * the confined argv, and re-assemble it into the `exec …` command string - * the inherited spawn path runs (the outer `bash -c` that `runBash` spawns + * the inherited spawn path runs (the outer `bash -c` the process manager spawns * `exec`s into the runner, so no extra shell lingers). Provider errors * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged. */ diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 6e8f2229a0..3510c71737 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -15,6 +15,7 @@ import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts' import type { Config } from '@deepseek-ai/dsh-bash-sandbox' @@ -58,9 +59,10 @@ async function setup( ...mode !== undefined ? { mode } : {}, ...workspaceRoot !== undefined ? { workspaceRoot } : {}, }) + await ctx.plugin(LocalProcessManager) + ;(ctx.processes as LocalProcessManager).internals = { spillDir } await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig }) const bash = ctx.bash as SandboxBashExecutor - bash.internals = { spillDir } return { ctx, bash, calls } } diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 93497df719..13c31ee2de 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -28,11 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-process": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-process": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index a504513417..5928687e93 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -1,19 +1,17 @@ /** * Execution types for the bash executor seam. Background task semantics belong - * to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. + * to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. The + * managed-environment and captured-output vocabulary is owned by the + * process-manager seam and re-exported here so bash consumers keep one import + * root. * @module dsh-bash/types */ import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-process' -/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ -export const DSH_ENV_PREFIX = 'DSH_' as const - -/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ -export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` - -/** Trusted DeepSeek Harness variables for one bash execution. */ -export type DshEnvironment = Readonly> +export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-process' +export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-process' /** * Sandbox facts for one run, present iff a sandboxing executor handled it. @@ -110,16 +108,6 @@ export interface BashExecSpec { sandboxPolicy: SandboxExecutionPolicy | undefined } -/** One captured stream: the (possibly truncated) text plus recovery info. */ -export interface CollectedOutput { - /** Collected text — the TAIL of the stream when truncated. */ - text: string - /** True when bytes were dropped from `text`. */ - truncated: boolean - /** Path to a file holding the COMPLETE stream, when truncated and available. */ - spillPath?: string -} - /** The outcome of one completed (or killed) foreground run. */ export interface BashRunResult { /** Exit code; null when the process died from a signal. */ diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index 51175e6080..ad38e8e3e1 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../process/process" + }, { "path": "../../sandbox/sandbox" }, diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index a89e147e0e..821e9706c9 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -52,6 +52,7 @@ "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index e1315c232a..1a1d8bb4db 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -11,6 +11,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -29,6 +30,7 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index c2b0c3d31b..3f22551b83 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -17,6 +17,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { processOutcome } from '../src/background.ts' @@ -32,8 +33,9 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalProcessManager) + ;(ctx.processes as LocalProcessManager).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) return ctx } @@ -46,8 +48,9 @@ async function setupWithTasks() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(LocalProcessManager) + ;(ctx.processes as LocalProcessManager).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) return ctx } @@ -275,8 +278,9 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalProcessManager) + ;(ctx.processes as LocalProcessManager).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' }) expect(text(result)).toContain('[output truncated; full output: ') @@ -383,6 +387,7 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, {}) const fiber = await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(1) @@ -400,6 +405,7 @@ describe('bash tool', () => { // inject: ['tools', 'bash'] keeps the plugin pending until bash exists. await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(0) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, {}) await new Promise(resolve => setTimeout(resolve, 0)) expect(ctx.tools.schemas()).toHaveLength(1) @@ -411,6 +417,7 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, {}) ToolBash.apply(ctx, {}) const schema = ctx.tools.schemas()[0]! @@ -526,6 +533,7 @@ describe('background execution through the task runtime', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, {}) await ctx.plugin(ToolBash, { enableRunInBackground: false }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 3681efa015..a15c306e2f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -426,6 +426,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'processes', + summary: 'Abstract process manager.', + methods: [ + { + signature: 'abstract spawn(spec: ProcessSpawnSpec): ProcessHandle', + jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, limits, grace, cancellation, and environment.\n * @returns the live process handle (readers, kill, outcome promise).\n */', + }, + ], + }, { key: 'pty', summary: 'In-process registry for replaceable PTY backends and exact-Agent sessions.', @@ -1760,6 +1770,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PresetSpec', declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}', }, + { + name: 'ProcessHandle', + declaration: 'export interface ProcessHandle {\n readonly pid: number;\n readonly stdout: ProcessOutputReader;\n readonly stderr: ProcessOutputReader;\n readonly done: Promise;\n kill(): void;\n}', + }, + { + name: 'ProcessOutcome', + declaration: 'export interface ProcessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}', + }, + { + name: 'ProcessOutputRead', + declaration: 'export interface ProcessOutputRead {\n text: string;\n nextOffset: number;\n lossy: boolean;\n spillPath?: string;\n}', + }, + { + name: 'ProcessOutputReader', + declaration: 'export interface ProcessOutputReader {\n readFrom(fromByte: number): ProcessOutputRead;\n}', + }, + { + name: 'ProcessSpawnSpec', + declaration: 'export interface ProcessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdoutMaxBytes: number;\n stderrMaxBytes: number;\n maxSpillBytes: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n}', + }, { name: 'PromptAssembly', declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record;\n}', diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 02e82ac5ac..3c101f14b5 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -94,6 +94,8 @@ async function makeConsumer(): Promise { await writeFile(join(dir, 'cordis.yml'), [ '- id: mock-llm', ' name: \'./mock-llm.mjs\'', + '- id: processes', + ' name: \'@deepseek-ai/dsh-process-local\'', '- id: bash', ' name: \'@deepseek-ai/dsh-bash-local\'', '- id: acp-agent', diff --git a/packages/examples/acp-demo/tests/load-path.e2e.ts b/packages/examples/acp-demo/tests/load-path.e2e.ts index 2c507a0bad..624db3d97a 100644 --- a/packages/examples/acp-demo/tests/load-path.e2e.ts +++ b/packages/examples/acp-demo/tests/load-path.e2e.ts @@ -35,6 +35,8 @@ const CORDIS_YML = ` name: '@deepseek-ai/dsh-llm-deepseek' config: apiKey: !!js process.env.DEEPSEEK_API_KEY +- id: processes + name: '@deepseek-ai/dsh-process-local' - id: bash name: '@deepseek-ai/dsh-bash-local' - id: acp-agent diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 5c3a6ad62e..0dcca62291 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -80,6 +80,8 @@ async function makeConsumer(): Promise { await writeFile(join(dir, 'cordis.yml'), [ '- id: mock-llm', " name: './mock-llm.ts'", + '- id: processes', + " name: '@deepseek-ai/dsh-process-local'", '- id: bash', " name: '@deepseek-ai/dsh-bash-local'", '- id: cli-agent', diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 45de8f5e4a..c0abc66a50 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index c497d7e428..3ba8e86c36 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -18,6 +18,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' const testToolSignal = new AbortController().signal @@ -61,6 +62,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 }) await ctx.plugin(ToolFsSearch) }) diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index 34696ab625..bf333718fa 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index f5fd5702d5..3cf70d569a 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -52,6 +53,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) @@ -353,6 +355,7 @@ describe('hooks-claude bridge — load resilience', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -374,6 +377,7 @@ describe('hooks-claude bridge — load resilience', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') }) await fiber.dispose() diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 8a3c44c5bd..1470c5f9a9 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -41,6 +42,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp await mountAgentLoopTestDependencies(ctx) if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath, ...opts }) ctx.llm.registerAdapter(['mock'], adapter) @@ -359,6 +361,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) // Direct apply with only configPath — bypasses schemastery's defaults, so // the bridge must run on the raw minimal config (the per-hook timeout is @@ -657,6 +660,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the session cwd). + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) @@ -686,6 +690,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the child session cwd). + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], new MockAdapter([])) diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 5d583baafe..472e010a93 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -45,6 +45,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 923a4bf8b5..eb34a5c1f8 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -41,6 +42,7 @@ async function harness(dir: string, adapter: MockAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -164,6 +166,7 @@ describe('hooks-codex bridge', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) await fiber.dispose() @@ -186,6 +189,7 @@ describe('hooks-codex bridge', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) ctx.llm.registerAdapter(['mock'], new MockAdapter([])) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index ebb2164902..4bfaf4f98a 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -31,6 +32,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp await mountAgentLoopTestDependencies(ctx) if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts }) ctx.llm.registerAdapter(['mock'], adapter) @@ -310,6 +312,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) ctx.logger.warn = warn as never // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. @@ -619,6 +622,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/process/README.md b/packages/process/README.md new file mode 100644 index 0000000000..956f08e3fe --- /dev/null +++ b/packages/process/README.md @@ -0,0 +1,10 @@ +# process/ — child-process manager capability family + +The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [process-manager seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md). + +| Package | ctx key | Role | +|---|---|---| +| [`process`](process/README.md) (`@deepseek-ai/dsh-process`) | `ctx.processes` | The seam: abstract `ProcessManager.spawn(spec)`, the fully-explicit `ProcessSpawnSpec`, `ProcessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary | +| [`process-local`](process-local/README.md) (`@deepseek-ai/dsh-process-local`) | — | The local implementation: detached process groups, tail-keep truncation with bounded private spill files, the credential scrub and `DSH_*` merge order, kill escalation, and kill-and-join disposal | + +The manager owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/process/process-local/README.md b/packages/process/process-local/README.md new file mode 100644 index 0000000000..595e42af2f --- /dev/null +++ b/packages/process/process-local/README.md @@ -0,0 +1,27 @@ +# @deepseek-ai/dsh-process-local + +Local-subprocess implementation of the [`@deepseek-ai/dsh-process`](../process/README.md) manager seam: `LocalProcessManager` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today). + +## Behavior (and where it came from) + +- **Detached process groups with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent). After the leader exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the spawn open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. +- **Tail-keep truncation + bounded spill files** — output beyond a stream's cap keeps the in-memory TAIL (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file whose path is reported when available. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. +- **Credential scrub + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +- **Offset-based reads** — `ProcessHandle` readers return deltas in whole-stream byte coordinates; the manager never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist. +- **Kill-and-join disposal** — the manager retains live handles only so its own disposal can kill every running group and await its exit; settled and spawn-failed handles leave the live set on settlement. + +## Model Experience + +Indirectly, through consumer seams (today the bash executor family behind `dsh-tool-bash`), which own all model-facing rendering of process output and lifecycle. + +#### KV Cache effect + +No direct invalidation; the named consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **POSIX-only** — detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported. +- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. +- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. + +The raw process handling lives in `src/spawn.ts`; `src/index.ts` is the service wiring. diff --git a/packages/process/process-local/package.json b/packages/process/process-local/package.json new file mode 100644 index 0000000000..22d51aadff --- /dev/null +++ b/packages/process/process-local/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-process-local", + "description": "Local-subprocess implementation of the DeepSeek Harness process-manager seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-process": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-process": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/process/process-local/src/index.ts b/packages/process/process-local/src/index.ts new file mode 100644 index 0000000000..6971a58a71 --- /dev/null +++ b/packages/process/process-local/src/index.ts @@ -0,0 +1,53 @@ +/** + * Local-subprocess implementation of the process-manager seam. Each spawn is + * a detached process group with bounded, spill-backed output; disposal kills + * and joins live groups. It has no config: every limit arrives on the spec, + * so the deployment-varying choices stay with the calling seam's config (the + * bash executor's, today). + * @module @deepseek-ai/dsh-process-local + */ + +import { Context } from 'cordis' +import { ProcessManager } from '@deepseek-ai/dsh-process' +import type { ProcessHandle, ProcessSpawnSpec } from '@deepseek-ai/dsh-process' +import { spawnProcess } from './spawn.ts' +import type { SpawnInternals } from './spawn.ts' + +/** + * Local process manager: detached process groups, tail-keep truncation with + * bounded spill files, credential-scrubbed environment, and group + * SIGTERM→grace→SIGKILL escalation. + */ +export class LocalProcessManager extends ProcessManager { + /** Live handles retained only so disposal can kill and join them. */ + private live = new Set() + /** Test seam: spill knobs forwarded to spawnProcess. */ + internals: SpawnInternals = {} + + constructor(ctx: Context) { + super(ctx) + ctx.effect(() => async () => { + // Await closure so even a TERM-trapping child cannot outlive the fiber. + const pending: Promise[] = [] + for (const handle of this.live) { + handle.kill() + // Spawn-failure rejections already settled and left the live set. + pending.push(handle.done.catch(() => {})) + } + this.live.clear() + await Promise.all(pending) + }, 'local process-manager teardown') + } + + spawn(spec: ProcessSpawnSpec): ProcessHandle { + const handle = spawnProcess(spec, this.internals) + this.live.add(handle) + handle.done.then( + () => { this.live.delete(handle) }, + () => { this.live.delete(handle) }, + ) + return handle + } +} + +export default LocalProcessManager diff --git a/packages/process/process-local/src/invariant.ts b/packages/process/process-local/src/invariant.ts new file mode 100644 index 0000000000..fa6c0c810e --- /dev/null +++ b/packages/process/process-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-process-local`. + * @module @deepseek-ai/dsh-process-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-process-local' + +/** Cordis companion plugin name. */ +export const name = 'process-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package exposes no independent event sequence or mutable data relation + * beyond contracts enforced at its owning seam. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/bash/bash-local/src/run.ts b/packages/process/process-local/src/spawn.ts similarity index 66% rename from packages/bash/bash-local/src/run.ts rename to packages/process/process-local/src/spawn.ts index 600e920c96..747b85cf13 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/process/process-local/src/spawn.ts @@ -1,8 +1,9 @@ /** - * Process plumbing for the local bash executor: detached process-group spawn, - * tail-keep output with spill files, and SIGTERM→SIGKILL escalation. This layer - * reacts to an abort signal; the executor owns deadlines and classifies causes. - * @module dsh-bash-local/run + * Process plumbing for the local process manager: detached process-group + * spawn, tail-keep output with spill files, and SIGTERM→SIGKILL escalation. + * This layer reacts to an abort signal; callers own deadlines and classify + * causes. + * @module dsh-process-local/spawn */ import { type ChildProcessByStdio, spawn } from 'node:child_process' @@ -11,23 +12,11 @@ import { randomBytes } from 'node:crypto' import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' -import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash' +import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-process' +import type { CollectedOutput, DshEnvironment, ProcessHandle, ProcessOutcome, ProcessSpawnSpec } from '@deepseek-ai/dsh-process' /** - * Model-friendly environment overrides: disable colors, pagers, and - * interactive terminal features that would garble tool output (the same set - * Codex hardcodes; Claude Code achieves it via TERM=dumb). - */ -export const ENV_OVERRIDES = { - NO_COLOR: '1', - TERM: 'dumb', - PAGER: 'cat', - GIT_PAGER: 'cat', -} as const - -/** - * Credential-shaped env vars are NOT forwarded to commands (the harness's + * Credential-shaped env vars are NOT forwarded to children (the harness's * own DEEPSEEK_API_KEY must not leak into `env` output, tool results, or * spill files). Same default pattern as Codex's env policy; a future config * can whitelist specific vars when a workflow genuinely needs one. @@ -35,10 +24,10 @@ export const ENV_OVERRIDES = { export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i /** - * Build a child environment from scrubbed ambient values, terminal overrides, - * ordinary caller entries, and a managed `DSH_*` snapshot. Ambient managed - * names are removed; ordinary and managed entries reject the other channel's - * namespace before `dshEnv` merges last. + * Build a child environment from scrubbed ambient values, ordinary caller + * entries, and a managed `DSH_*` snapshot. Ambient managed names are removed; + * ordinary and managed entries reject the other channel's namespace before + * `dshEnv` merges last. * @param extra - caller entries; `DSH_*` names are rejected. * @param dshEnv - managed entries; non-`DSH_*` names are rejected. * @returns the environment to hand to `spawn` for the child process. @@ -53,77 +42,23 @@ export function childEnv( } for (const key of Object.keys(extra ?? {})) { if (key.startsWith(DSH_ENV_PREFIX)) { - throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`) + throw new Error(`ordinary child env cannot set reserved variable "${key}"; use dshEnv`) } } for (const key of Object.keys(dshEnv ?? {})) { if (!key.startsWith(DSH_ENV_PREFIX)) { - throw new Error(`managed bash env cannot set ordinary variable "${key}"; use env`) + throw new Error(`managed child env cannot set ordinary variable "${key}"; use env`) } } - return { ...env, ...ENV_OVERRIDES, ...extra, ...dshEnv } -} - -/** What to run and under which limits (resolved — no defaults in here). */ -export interface SpawnSpec { - command: string - 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 shell exit. */ - graceMs: number - /** - * Abort signal — kills the process group when it fires. The executor owns - * timing: `run()` passes a fused timeout/cancel deadline signal (see - * `@deepseek-ai/dsh-timeout`), `start()` passes the bare upstream signal. - * runBash only listens and kills; it does NOT classify why (the executor - * reads the signal's reason afterward). - */ - signal?: AbortSignal | undefined - /** - * Bytes to write to the child's stdin, then close it. Absent (or empty) - * leaves stdin closed/empty. Set by in-process plugins (the hooks bridges); - * the model-facing `dsh-tool-bash` tool does not thread model input here. - */ - stdin?: string | undefined - /** - * Ordinary environment entries merged after the credential scrub and - * terminal overrides. `DSH_*` names are rejected and belong in `dshEnv`. - */ - env?: Record | undefined - /** Harness-owned entries; non-`DSH_*` names are rejected before spawn. */ - dshEnv?: DshEnvironment | undefined -} - -/** - * Raw outcome of one closed process (before result shaping). Deliberately - * carries NO timeout/cancel classification: runBash kills on abort but does not - * decide why — the executor's `run()`/`start()` reads the deadline signal it - * owns to classify `timedOut`/`aborted` (see the package README). - */ -export interface SpawnOutcome { - exitCode: number | null - signal: NodeJS.Signals | null - stdout: CollectedOutput - stderr: CollectedOutput + return { ...env, ...extra, ...dshEnv } } /** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */ -export interface RunInternals { +export interface SpawnInternals { /** Directory for spill files (defaults to the OS temp dir). */ spillDir?: string } -/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ -export const DEFAULT_GRACE_MS = 3_000 - -/** Default per-stream spill cap (the `maxSpillBytes` config). */ -export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 - let spillCounter = 0 let defaultSpillDir: string | undefined @@ -133,7 +68,7 @@ let defaultSpillDir: string | undefined * other local users read command output or pre-create symlinks. */ function privateSpillDir(): string { - defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-bash-')) + defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-proc-')) return defaultSpillDir } @@ -205,7 +140,7 @@ export class OutputCollector { // prediction and symlink planting in shared tmp dirs. this.spillFile = join( this.spillDir, - `dsh-bash-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`, + `dsh-proc-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`, ) this.spillFd = openSync(this.spillFile, 'wx', 0o600) for (const prior of this.chunks) writeSync(this.spillFd, prior) @@ -300,41 +235,28 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void { } /** - * A live bash child process: the promise resolves when the process closes; - * `kill()` starts the SIGTERM→grace→SIGKILL escalation on its group. - */ -export interface RunningBash { - /** Process id (group leader); -1 when the spawn itself failed. */ - readonly pid: number - /** stdout/stderr collectors (live — background polling reads incrementally). */ - readonly stdout: OutputCollector - readonly stderr: OutputCollector - /** Resolves when the process closes; rejects only for spawn-level failures. */ - readonly done: Promise - /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ - kill(): void -} - -/** - * Spawn one isolated `bash -c` process group and collect its output. - * Runtime exits resolve as {@link SpawnOutcome}; only spawn failures reject. - * @param spec - fully resolved command, cwd, limits, and cancellation. - * @param internals - test-only process and spill-directory overrides. + * Spawn one isolated detached process group and collect its output. + * Runtime exits resolve as {@link ProcessOutcome}; only spawn failures reject. + * @param spec - fully resolved argv, cwd, limits, and cancellation. + * @param internals - test-only spill-directory override. * @returns live process handle and outcome promise. */ -// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. -export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash { +export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals = {}): ProcessHandle { const spillDir = internals.spillDir ?? privateSpillDir() if (spec.signal?.aborted) { throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) } + const [program, ...args] = spec.argv + if (program === undefined || program.length === 0) { + throw new Error('invalid argv: expected a non-empty program name at argv[0]') + } // Keep absent stdin as /dev/null; literal tuples preserve non-null output types. const env = childEnv(spec.env, spec.dshEnv) const child: ChildProcessByStdio = spec.stdin !== undefined - ? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }) - : spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) + ? spawn(program, args, { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }) + : spawn(program, args, { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir) const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir) @@ -352,7 +274,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs) } - // The executor owns timeout classification; this layer only reacts to abort. + // The caller owns timeout classification; this layer only reacts to abort. const onAbort = (): void => { kill() } spec.signal?.addEventListener('abort', onAbort, { once: true }) @@ -362,7 +284,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB child.stdin.end(spec.stdin) } - const done = new Promise((resolve, reject) => { + const done = new Promise((resolve, reject) => { let settled = false let pipeDrainTimer: NodeJS.Timeout | undefined const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { diff --git a/packages/process/process-local/tests/manager.spec.ts b/packages/process/process-local/tests/manager.spec.ts new file mode 100644 index 0000000000..719dcc1d44 --- /dev/null +++ b/packages/process/process-local/tests/manager.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import type { ProcessSpawnSpec } from '@deepseek-ai/dsh-process' + +function spec(command: string, overrides: Partial = {}): ProcessSpawnSpec { + return { + argv: ['bash', '-c', command], + cwd: process.cwd(), + stdoutMaxBytes: 64_000, + stderrMaxBytes: 64_000, + maxSpillBytes: 64 * 1024 * 1024, + graceMs: 200, + ...overrides, + } +} + +describe('LocalProcessManager', () => { + it('registers as ctx.processes and spawns managed handles', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalProcessManager) + const result = await ctx.processes.spawn(spec('echo managed')).done + expect(result.exitCode).toBe(0) + expect(result.stdout.text).toBe('managed\n') + await fiber.dispose() + }) + + it('disposal kills still-running processes and awaits their exit', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalProcessManager) + const handle = ctx.processes.spawn(spec('sleep 60')) + await fiber.dispose() + const outcome = await handle.done + expect(outcome.signal).toBe('SIGTERM') + }) + + it('a settled process leaves the live set (disposal does not re-kill it)', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalProcessManager) + const handle = ctx.processes.spawn(spec('true')) + const outcome = await handle.done + expect(outcome.exitCode).toBe(0) + await fiber.dispose() + }) + + it('disposal tolerates a handle whose spawn already failed', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalProcessManager) + const handle = ctx.processes.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-manager-test' })) + await expect(handle.done).rejects.toThrow() + await fiber.dispose() + }) + + it('disposal contains a spawn-failure rejection that races teardown', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(LocalProcessManager) + // Dispose before the rejection continuation removes the handle from the + // live set, so teardown itself must swallow the rejected done. + const handle = ctx.processes.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-manager-test' })) + await fiber.dispose() + await expect(handle.done).rejects.toThrow() + }) + + it('loading a second implementation throws (one processes service per context — cordis standard)', async () => { + const ctx = new Context() + await ctx.plugin(LocalProcessManager) + class SecondManager extends LocalProcessManager {} + await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "processes" has been registered/) + }) +}) diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/process/process-local/tests/spawn.spec.ts similarity index 78% rename from packages/bash/bash-local/tests/run.spec.ts rename to packages/process/process-local/tests/spawn.spec.ts index 91afd1aede..954196e104 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/process/process-local/tests/spawn.spec.ts @@ -2,9 +2,9 @@ import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import type { DshEnvironment } from '@deepseek-ai/dsh-bash' -import { killGroup, OutputCollector, runBash } from '../src/run.ts' -import type { RunningBash } from '../src/run.ts' +import type { DshEnvironment } from '@deepseek-ai/dsh-process' +import { killGroup, OutputCollector, spawnProcess } from '../src/spawn.ts' +import type { ProcessHandle } from '@deepseek-ai/dsh-process' const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ failNextClose: { value: false }, @@ -31,11 +31,11 @@ vi.mock('node:fs', async (importOriginal) => { } }) -const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-spec-')) +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-proc-spec-')) -function spec(command: string, overrides: Partial[0]> = {}) { +function spec(command: string, overrides: Partial[0]> = {}) { return { - command, + argv: ['bash', '-c', command], cwd: process.cwd(), stdoutMaxBytes: 64_000, stderrMaxBytes: 64_000, @@ -59,7 +59,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise { throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`) } -async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise { +async function waitForStdout(running: ProcessHandle, expected: string, timeoutMs = 5_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { if (running.stdout.readFrom(0).text.includes(expected)) return @@ -82,9 +82,9 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`) } -describe('runBash', () => { +describe('spawnProcess', () => { it('captures stdout on success', async () => { - const result = await runBash(spec('echo hello')).done + const result = await spawnProcess(spec('echo hello')).done expect(result.exitCode).toBe(0) expect(result.signal).toBeNull() expect(result.stdout.text).toBe('hello\n') @@ -93,41 +93,43 @@ describe('runBash', () => { }) it('captures stderr separately', async () => { - const result = await runBash(spec('echo oops >&2')).done + const result = await spawnProcess(spec('echo oops >&2')).done expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('') expect(result.stderr.text).toBe('oops\n') }) it('captures both streams', async () => { - const result = await runBash(spec('echo out; echo err >&2')).done + const result = await spawnProcess(spec('echo out; echo err >&2')).done expect(result.stdout.text).toBe('out\n') expect(result.stderr.text).toBe('err\n') }) it('reports non-zero exit codes', async () => { - const result = await runBash(spec('exit 42')).done + const result = await spawnProcess(spec('exit 42')).done expect(result.exitCode).toBe(42) expect(result.signal).toBeNull() }) - it('applies model-friendly env overrides', async () => { - const result = await runBash(spec('echo "$NO_COLOR/$TERM/$PAGER"')).done - expect(result.stdout.text).toBe('1/dumb/cat\n') + it('passes the ambient TERM through untouched (terminal policy is the caller\'s)', async () => { + const result = await spawnProcess(spec('echo "${TERM:-unset}"', { + env: { TERM: 'callers-choice' }, + })).done + expect(result.stdout.text).toBe('callers-choice\n') }) it('runs in the requested cwd', async () => { - const result = await runBash(spec('pwd', { cwd: '/tmp' })).done + const result = await spawnProcess(spec('pwd', { cwd: '/tmp' })).done expect(result.stdout.text.trim()).toMatch(/\/tmp$/) }) it('kills the process group with SIGTERM when the signal fires', async () => { - // runBash owns no timer: it kills on abort. The executor drives the timeout + // spawnProcess owns no timer: it kills on abort. The bash executor drives the timeout // by firing this signal via a deadline (see executor.spec.ts); here we // assert the kill itself lands as SIGTERM. const controller = new AbortController() const start = Date.now() - const running = runBash(spec('sleep 60', { signal: controller.signal })) + const running = spawnProcess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort('deadline') }, 100) const result = await running.done expect(Date.now() - start).toBeLessThan(5_000) @@ -136,7 +138,7 @@ describe('runBash', () => { }) it('escalates to SIGKILL when SIGTERM is trapped', async () => { - const running = runBash(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) + const running = spawnProcess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) await waitForStdout(running, 'ready\n') running.kill() const result = await running.done @@ -147,7 +149,7 @@ describe('runBash', () => { // The subshell writes the sleep's pid then waits on it; killing the // group must take the sleep down with bash. const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`) - const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) + const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) const grandchild = await waitForPidFile(pidFile) expect(grandchild).toBeGreaterThan(0) @@ -159,7 +161,7 @@ describe('runBash', () => { it('aborts via AbortSignal mid-run', async () => { const controller = new AbortController() - const running = runBash(spec('sleep 60', { signal: controller.signal })) + const running = spawnProcess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort('user cancelled') }, 50) const result = await running.done expect(result.signal).toBe('SIGTERM') @@ -168,17 +170,17 @@ describe('runBash', () => { it('throws when the signal is already aborted before spawn', () => { const controller = new AbortController() controller.abort('too late') - expect(() => runBash(spec('echo hi', { signal: controller.signal }))) + expect(() => spawnProcess(spec('echo hi', { signal: controller.signal }))) .toThrow(/aborted before spawn: too late/) }) it('rejects with a spawn error for a nonexistent cwd', async () => { - await expect(runBash(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done) + await expect(spawnProcess(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done) .rejects.toThrow(/ENOENT/) }) it('kill() is idempotent (second call does not restart escalation)', async () => { - const running = runBash(spec('sleep 60')) + const running = spawnProcess(spec('sleep 60')) running.kill() running.kill() const result = await running.done @@ -188,7 +190,7 @@ describe('runBash', () => { it('bounds inherited-pipe draining after the shell exits', async () => { const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) const started = Date.now() - const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) + const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) const descendant = await waitForPidFile(pidFile) try { const result = await running.done @@ -204,7 +206,7 @@ describe('runBash', () => { describe('stdin and extra env (set by in-process plugins)', () => { it('writes stdin to the command and closes it', async () => { - const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done + const result = await spawnProcess(spec('cat', { stdin: 'hello from stdin\n' })).done expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('hello from stdin\n') }) @@ -212,7 +214,7 @@ describe('stdin and extra env (set by in-process plugins)', () => { it('a command that reads stdin sees EOF when none is supplied', async () => { // No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no // output (it does NOT block). - const result = await runBash(spec('cat')).done + const result = await spawnProcess(spec('cat')).done expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('') }) @@ -220,41 +222,40 @@ describe('stdin and extra env (set by in-process plugins)', () => { it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { // With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device). // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO. - const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done + const none = await spawnProcess(spec('test -c /dev/stdin && echo char || echo other')).done expect(none.stdout.text).toBe('char\n') - const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done + const piped = await spawnProcess(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done expect(piped.stdout.text).toBe('socket\n') }) it('merges ordinary extra env entries onto the scrubbed environment', async () => { - const result = await runBash(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', { + const result = await spawnProcess(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', { env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' }, })).done expect(result.stdout.text).toBe('alpha/beta\n') }) - it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => { - // TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins. + it('an explicit extra env entry overrides the credential scrub', async () => { // EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit // entry is still honored — the scrub only drops AMBIENT process.env creds. - const result = await runBash(spec('echo "$TERM/$EXPLICIT_OVERRIDE_KEY"', { - env: { TERM: 'xterm-256color', EXPLICIT_OVERRIDE_KEY: 'explicit-wins' }, + const result = await spawnProcess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', { + env: { EXPLICIT_OVERRIDE_KEY: 'explicit-wins' }, })).done - expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n') + expect(result.stdout.text).toBe('explicit-wins\n') }) it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => { // The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE. // The handler swallows that write error and `done` reports the child's real exit. const big = 'x'.repeat(1024 * 1024) - const result = await runBash(spec('exit 7', { stdin: big })).done + const result = await spawnProcess(spec('exit 7', { stdin: big })).done expect(result.exitCode).toBe(7) }) }) describe('output truncation and spill', () => { it('applies stdout and stderr caps independently', async () => { - const result = await runBash( + const result = await spawnProcess( spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', { stdoutMaxBytes: 500, stderrMaxBytes: 100, @@ -269,7 +270,7 @@ describe('output truncation and spill', () => { it('keeps the tail and spills the full stream to disk', async () => { // 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail. - const result = await runBash( + const result = await spawnProcess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, ).done @@ -284,7 +285,7 @@ describe('output truncation and spill', () => { }) it('does not truncate output exactly at the cap', async () => { - const result = await runBash( + const result = await spawnProcess( spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, ).done @@ -295,7 +296,7 @@ describe('output truncation and spill', () => { it('settles with the tail and no spill path when final spill close fails', async () => { failNextClose.value = true - const result = await runBash( + const result = await spawnProcess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, ).done @@ -402,12 +403,27 @@ describe('killGroup', () => { }) it('swallows ESRCH for vanished groups', async () => { - const running = runBash(spec('true')) + const running = spawnProcess(spec('true')) await running.done expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() }) }) +describe('argv validation', () => { + it('rejects an empty argv before spawning', () => { + expect(() => spawnProcess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/) + }) + + it('rejects an empty program name before spawning', () => { + expect(() => spawnProcess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/) + }) + + it('spawns argv verbatim without shell interpretation', async () => { + const result = await spawnProcess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] }).done + expect(result.stdout.text).toBe('$HOME') + }) +}) + describe('abort edge cases', () => { it('reports a fallback reason for reason-less pre-aborted signals', () => { // Real AbortControllers always set a DOMException reason; signal-like @@ -418,14 +434,14 @@ describe('abort edge cases', () => { addEventListener() {}, removeEventListener() {}, } as unknown as AbortSignal - expect(() => runBash(spec('echo hi', { signal: bare }))) + expect(() => spawnProcess(spec('echo hi', { signal: bare }))) .toThrow(/aborted before spawn: aborted/) }) it('reports the terminating signal of an externally self-killed command', async () => { - // runBash reports the raw signal; whether it counts as timeout/cancel is the + // spawnProcess reports the raw signal; whether it counts as timeout/cancel is the // executor's classification (a self-kill is neither) — see executor.spec.ts. - const result = await runBash(spec('kill -TERM $$')).done + const result = await spawnProcess(spec('kill -TERM $$')).done expect(result.signal).toBe('SIGTERM') }) }) @@ -436,7 +452,7 @@ describe('environment and spill-file hardening', () => { process.env.DSH_TEST_TOKEN = 'also-secret' process.env.DSH_TEST_PLAIN = 'visible' try { - const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done + const result = await spawnProcess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done expect(result.stdout.text.trim()).toBe('[absent|absent|absent]') } finally { delete process.env.DSH_TEST_API_KEY @@ -448,7 +464,7 @@ describe('environment and spill-file hardening', () => { it('injects only the current trusted DSH environment after scrubbing ambient values', async () => { process.env.DSH_STALE = 'old-value' try { - const result = await runBash(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { + const result = await spawnProcess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' }, })).done expect(result.stdout.text.trim()).toBe('[absent|1|current-session]') @@ -458,33 +474,33 @@ describe('environment and spill-file hardening', () => { }) it('rejects DSH variables on the ordinary env channel', () => { - expect(() => runBash(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } }))) + expect(() => spawnProcess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } }))) .toThrow(/DSH_WRONG_CHANNEL.*dshEnv/) }) it('rejects ordinary variables on the managed env channel', () => { const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment - expect(() => runBash(spec('true', { dshEnv: invalid }))) - .toThrow(/managed bash env.*PATH.*use env/) + expect(() => spawnProcess(spec('true', { dshEnv: invalid }))) + .toThrow(/managed child env.*PATH.*use env/) }) it('creates spill files with owner-only permissions and random names', async () => { - const result = await runBash( + const result = await spawnProcess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, ).done const path = result.stdout.spillPath! - expect(path).toMatch(/dsh-bash-\d+-\d+-[0-9a-f]{12}-stdout\.log$/) + expect(path).toMatch(/dsh-proc-\d+-\d+-[0-9a-f]{12}-stdout\.log$/) const mode = statSync(path).mode & 0o777 expect(mode).toBe(0o600) }) it('defaults spills into a private per-process directory', async () => { - const result = await runBash( + const result = await spawnProcess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), ).done const dir = dirname(result.stdout.spillPath!) - expect(dir).toMatch(/dsh-bash-/) + expect(dir).toMatch(/dsh-proc-/) const mode = statSync(dir).mode & 0o777 expect(mode).toBe(0o700) }) @@ -502,7 +518,7 @@ describe('environment and spill-file hardening', () => { it('honors AbortSignal on background-style runs (no timeout)', async () => { const controller = new AbortController() - const running = runBash(spec('sleep 60', { signal: controller.signal })) + const running = spawnProcess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort() }, 50) const result = await running.done expect(result.signal).toBe('SIGTERM') diff --git a/packages/process/process-local/tsconfig.json b/packages/process/process-local/tsconfig.json new file mode 100644 index 0000000000..5f84f780af --- /dev/null +++ b/packages/process/process-local/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../process" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/process/process/README.md b/packages/process/process/README.md new file mode 100644 index 0000000000..b53628cab3 --- /dev/null +++ b/packages/process/process/README.md @@ -0,0 +1,26 @@ +# @deepseek-ai/dsh-process + +The child-process manager seam (`ctx.processes`). The abstract `ProcessManager` exposes one method — `spawn(spec): ProcessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `ProcessSpawnSpec`, `ProcessHandle` with its non-consuming offset-based output readers, `ProcessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). The local implementation lives in [`dsh-process-local`](../process-local/README.md). + +## Contract + +- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures. +- The spec is fully explicit — argv, cwd, per-stream byte caps, spill cap, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden process-manager default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted here; a consumer that wants a shell passes `['bash', '-c', command]` itself. +- Output readers take whole-stream byte offsets and never consume: independent readers cannot steal one another's deltas. A read whose offset slid out of the in-memory tail is `lossy` and points at the full-stream spill file when one exists. +- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the manager reacts to the abort but never classifies why (callers own deadlines and cause classification). +- Disposal kills all still-running managed processes and awaits their exit. + +See the [process data-structure catalog](../../../docs/core-data-structures/process.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md). + +## Model Experience + +Indirectly, through consumer seams (today the bash executor family behind `dsh-tool-bash`), which own all model-facing rendering of process output and lifecycle. + +#### KV Cache effect + +No direct invalidation; the named consumers own any request-prefix changes. + +## Known Limitations and Deferred Work + +- **One consumer family so far** — the seam's shape is proven against the bash executors only; the other in-repo spawn sites (LSP servers, PTY backends, subagent transports) keep their own bespoke process handling until their stream/lifecycle needs are re-examined against this contract. +- **POSIX group semantics are assumed** — the handle vocabulary (`pid` as group leader, group kills, SIGTERM/SIGKILL escalation) has no Windows story. diff --git a/packages/process/process/package.json b/packages/process/process/package.json new file mode 100644 index 0000000000..6210948d56 --- /dev/null +++ b/packages/process/process/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-process", + "description": "Child-process manager seam (ctx.processes) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/process/process/src/index.ts b/packages/process/process/src/index.ts new file mode 100644 index 0000000000..4fe3386503 --- /dev/null +++ b/packages/process/process/src/index.ts @@ -0,0 +1,62 @@ +/** + * The child-process manager seam (`ctx.processes`): spawn fully-specified + * commands into managed process groups with bounded, spill-backed output and + * escalated kills. Command defaulting, shell semantics, deadlines, and + * presentation belong to consumers — the bash executor seam is the owning + * template. The local implementation lives in + * `@deepseek-ai/dsh-process-local`. + * @module @deepseek-ai/dsh-process + */ + +import { Context, Service } from 'cordis' +import type { ProcessHandle, ProcessSpawnSpec } from './types.ts' + +export { DSH_ENV_PREFIX } from './types.ts' +export type { + CollectedOutput, + DshEnvironment, + DshEnvironmentKey, + ProcessHandle, + ProcessOutcome, + ProcessOutputRead, + ProcessOutputReader, + ProcessSpawnSpec, +} from './types.ts' + +declare module 'cordis' { + interface Context { + processes: ProcessManager + } +} + +/** + * Abstract process manager. Subclass, implement {@link spawn}, and load the + * subclass as a plugin — it registers as `ctx.processes` (one implementation + * per context; loading a second throws, which is cordis' standard + * duplicate-service behavior). + * + * Implementations must honor these semantics: + * - {@link spawn} returns immediately with a live handle; `done` resolves at + * process close and rejects only for spawn-level failures. + * - Output readers are offset-based and non-consuming, so independent readers + * never consume one another's output; lossy reads report truncation and the + * spill file holding the complete stream when one exists. + * - {@link ProcessHandle.kill} and the spec's abort signal escalate + * SIGTERM→grace→SIGKILL across the whole process group. + * - Disposal kills all still-running managed processes and awaits their exit. + */ +export abstract class ProcessManager extends Service { + constructor(ctx: Context) { + super(ctx, 'processes') + } + + /** + * Start one managed child process from a fully-specified spec; this seam + * applies no defaults. + * @param spec - argv, directory, limits, grace, cancellation, and environment. + * @returns the live process handle (readers, kill, outcome promise). + */ + abstract spawn(spec: ProcessSpawnSpec): ProcessHandle +} + +export default ProcessManager diff --git a/packages/process/process/src/invariant.ts b/packages/process/process/src/invariant.ts new file mode 100644 index 0000000000..f54476747e --- /dev/null +++ b/packages/process/process/src/invariant.ts @@ -0,0 +1,22 @@ +/** Package-owned invariant companion for the process-manager seam. @module @deepseek-ai/dsh-process/invariant */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-process' + +/** Cordis companion plugin name. */ +export const name = 'process-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: this stateless seam owns spawn-spec/handle types, while implementations own observations. */ +const install: InvariantInstaller = () => {} + +/** + * Register the process-manager invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/process/process/src/types.ts b/packages/process/process/src/types.ts new file mode 100644 index 0000000000..1b81ab8c07 --- /dev/null +++ b/packages/process/process/src/types.ts @@ -0,0 +1,128 @@ +/** + * Vocabulary for the process-manager seam: fully-specified spawn requests, + * bounded output with spill recovery, and live process handles. Command + * defaulting, shell semantics, and presentation belong to consumers such as + * the bash executor seam. + * @module dsh-process/types + */ + +/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ +export const DSH_ENV_PREFIX = 'DSH_' as const + +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ +export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` + +/** Trusted DeepSeek Harness variables for one child-process execution. */ +export type DshEnvironment = Readonly> + +/** One captured stream: the (possibly truncated) text plus recovery info. */ +export interface CollectedOutput { + /** Collected text — the TAIL of the stream when truncated. */ + text: string + /** True when bytes were dropped from `text`. */ + truncated: boolean + /** Path to a file holding the COMPLETE stream, when truncated and available. */ + spillPath?: string +} + +/** + * A fully-specified spawn request. This seam applies no defaults: every limit + * and directory is explicit, so the caller's own config — not a hidden + * process-manager default — decides them (the `dsh-bash` request/spec split + * is the owning template). + */ +export 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 | undefined + /** + * Harness-owned `DSH_*` variables for this execution. Implementations + * discard ambient `DSH_*` entries before merging this snapshot, so an + * unavailable current fact cannot inherit a stale value from the harness + * process, and reject non-`DSH_*` names supplied through this channel. + */ + dshEnv?: DshEnvironment | undefined +} + +/** + * Raw outcome of one closed process. Deliberately carries NO timeout or + * cancellation classification: the manager kills on abort but does not decide + * why — the caller reads the signal it owns to classify causes. + */ +export 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 +} + +/** One incremental {@link ProcessOutputReader.readFrom} read. */ +export 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 +} + +/** + * Cursor-free incremental access to one live output stream. Offsets are + * whole-stream byte coordinates owned by the caller, so independent readers + * cannot consume one another's output. + */ +export interface 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 +} + +/** + * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL + * escalation; buffered output remains readable after exit. + */ +export 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 + /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ + kill(): void +} diff --git a/packages/process/process/tests/service.spec.ts b/packages/process/process/tests/service.spec.ts new file mode 100644 index 0000000000..04ffa8c9f4 --- /dev/null +++ b/packages/process/process/tests/service.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { ProcessManager } from '@deepseek-ai/dsh-process' +import type { ProcessHandle, ProcessOutputRead, ProcessSpawnSpec } from '@deepseek-ai/dsh-process' + +/** + * Minimal concrete manager: a hand-built handle. The seam is spawn-only — + * defaulting, shell semantics, and deadlines belong to callers — so this stub + * is all an implementation owes the abstract class. + */ +class StubProcessManager extends ProcessManager { + spawn(spec: ProcessSpawnSpec): ProcessHandle { + const read: ProcessOutputRead = { text: '', nextOffset: 0, lossy: false } + let killed = false + return { + pid: spec.argv.length, + stdout: { readFrom: () => read }, + stderr: { readFrom: () => read }, + done: Promise.resolve({ + exitCode: killed ? null : 0, + signal: null, + stdout: { text: 'ok', truncated: false }, + stderr: { text: '', truncated: false }, + }), + kill: () => { killed = true }, + } + } +} + +describe('ProcessManager seam', () => { + it('a concrete subclass registers as ctx.processes and serves the abstract API', async () => { + const ctx = new Context() + await ctx.plugin(StubProcessManager) + const handle = ctx.processes.spawn({ + argv: ['true'], + cwd: '/stub', + stdoutMaxBytes: 1, + stderrMaxBytes: 1, + maxSpillBytes: 1, + graceMs: 1, + }) + expect(handle.pid).toBe(1) + expect(handle.stdout.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) + handle.kill() + const outcome = await handle.done + expect(outcome.stdout.text).toBe('ok') + }) + + it('loading a second implementation throws (one processes service per context — cordis standard)', async () => { + const ctx = new Context() + await ctx.plugin(StubProcessManager) + class SecondManager extends StubProcessManager {} + await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "processes" has been registered/) + }) +}) diff --git a/packages/process/process/tsconfig.json b/packages/process/process/tsconfig.json new file mode 100644 index 0000000000..9966c8ca8a --- /dev/null +++ b/packages/process/process/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index 48b50977f8..a6873b75dc 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -32,7 +32,10 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry summary: 'Command execution', mode: 'exclusive', required: true, - baseResources: [{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }], + baseResources: [ + { kind: 'npm-cordis-config-entry', id: 'processes', package: '@deepseek-ai/dsh-process-local' }, + { kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }, + ], options: [ { id: 'local', diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index f429025a5d..6ab0d4f45f 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index 3b9342a90e..3bebb612c4 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -3,6 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -27,6 +28,7 @@ export async function spawnHarness(workdir: string): Promise { }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek) + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(SubagentService) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ed728d64d..c0471ffc91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,6 +182,9 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../packages/process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session @@ -424,6 +427,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:* version: link:../packages/plan/plan-mode + '@deepseek-ai/dsh-process-local': + specifier: workspace:* + version: link:../packages/process/process-local '@deepseek-ai/dsh-pty': specifier: workspace:* version: link:../packages/pty/pty @@ -585,6 +591,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-process': + specifier: workspace:^ + version: link:../../process/process '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -604,6 +613,12 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-process': + specifier: workspace:^ + version: link:../../process/process + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout @@ -622,6 +637,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -668,6 +686,9 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -1980,6 +2001,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-retention': specifier: workspace:^ version: link:../../util/retention @@ -2200,6 +2224,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2249,6 +2276,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2613,6 +2643,27 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/process/process: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/process/process-local: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-process': + specifier: workspace:^ + version: link:../process + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/pty/pty: devDependencies: '@deepseek-ai/dsh-agent': @@ -3569,6 +3620,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -4582,6 +4636,12 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-process': + specifier: workspace:^ + version: link:../../packages/process/process + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../packages/process/process-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index abf943793d..6fca02ccf8 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -40,6 +40,8 @@ "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-process": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml index 824aa03e7b..b59b94a257 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -33,6 +33,10 @@ name: '@deepseek-ai/dsh-session-checkpoint-policy' # Local bash executor; $DSH_CWD wins over the process cwd. +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: processes + name: '@deepseek-ai/dsh-process-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py index 6ff14fa266..755e65e00a 100644 --- a/python/sdk/tests/test_bundled_runtime.py +++ b/python/sdk/tests/test_bundled_runtime.py @@ -30,6 +30,8 @@ _CORDIS_YML = """\ root: './sessions' - id: session-checkpoints name: '@deepseek-ai/dsh-session-checkpoint-policy' +- id: processes + name: '@deepseek-ai/dsh-process-local' - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index c2fee63c70..a8e259d16b 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 660, - "packages/README.md": 790 + "packages/README.md": 810 } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b53d1eaa12..07adb0ba0c 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -64,7 +64,12 @@ export const LINK_MAP: Record = { BashExecSpec: 'bash.md', BashProcess: 'bash.md', BashRunResult: 'bash.md', - DshEnvironment: 'bash.md', + DshEnvironment: 'process.md', + ProcessHandle: 'process.md', + ProcessOutcome: 'process.md', + ProcessOutputRead: 'process.md', + ProcessOutputReader: 'process.md', + ProcessSpawnSpec: 'process.md', CodeRunRequest: 'code-runtime.md', CodeRunResult: 'code-runtime.md', CompactionResult: 'compaction.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 2551f95df9..05cec1dbad 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -59,6 +59,7 @@ const GROUP_ORDER = [ 'llm', 'core', 'goal', + 'process', 'bash', 'pty', 'sandbox', @@ -264,6 +265,15 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.', }, + { + key: 'processes', + pkg: 'process', + title: 'Process manager seam', + mode: 'seam', + implementations: ['process-local'], + consumers: ['bash-local', 'bash-sandbox'], + note: 'The bash executors spawn their process groups through ctx.processes; the manager owns group lifetime, bounded spill-backed output, and kill escalation.', + }, { key: 'bash', pkg: 'bash', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 45aa58d74e..5d787814eb 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -19,6 +19,7 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import PlanModeService from '@deepseek-ai/dsh-plan-mode' @@ -197,6 +198,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'], writes: ['tool/call', 'tool/result'], async mount(ctx) { + await ctx.plugin(LocalProcessManager) await ctx.plugin(LocalBashExecutor) await ctx.plugin(ToolBash) }, diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 0310306bfa..629cfe4038 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -729,16 +729,6 @@ "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" }, - { - "doc": "docs/core-data-structures/bash.md", - "symbol": "DshEnvironmentKey", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.md", - "symbol": "DshEnvironment", - "source": "packages/bash/bash/src/types.ts" - }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", @@ -759,11 +749,6 @@ "symbol": "BashSandboxInfo", "source": "packages/bash/bash/src/types.ts" }, - { - "doc": "docs/core-data-structures/bash.md", - "symbol": "CollectedOutput", - "source": "packages/bash/bash/src/types.ts" - }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashProcess", @@ -1867,16 +1852,6 @@ "symbol": "ApprovalRequest", "source": "packages/ui/user-approval/src/index.ts" }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "DshEnvironmentKey", - "source": "packages/bash/bash/src/types.ts" - }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "DshEnvironment", - "source": "packages/bash/bash/src/types.ts" - }, { "doc": "docs/core-data-structures/bash.zh.md", "symbol": "BashExecRequest", @@ -1897,11 +1872,6 @@ "symbol": "BashSandboxInfo", "source": "packages/bash/bash/src/types.ts" }, - { - "doc": "docs/core-data-structures/bash.zh.md", - "symbol": "CollectedOutput", - "source": "packages/bash/bash/src/types.ts" - }, { "doc": "docs/core-data-structures/bash.zh.md", "symbol": "BashProcess", @@ -2191,6 +2161,31 @@ "doc": "docs/core-data-structures/workflow.zh.md", "symbol": "WorkflowRun", "source": "packages/workflow/workflow/src/types.ts" + }, + { + "doc": "docs/core-data-structures/process.md", + "symbol": "ProcessSpawnSpec", + "source": "packages/process/process/src/types.ts" + }, + { + "doc": "docs/core-data-structures/process.md", + "symbol": "ProcessHandle", + "source": "packages/process/process/src/types.ts" + }, + { + "doc": "docs/core-data-structures/process.md", + "symbol": "ProcessOutputReader", + "source": "packages/process/process/src/types.ts" + }, + { + "doc": "docs/core-data-structures/process.md", + "symbol": "ProcessOutputRead", + "source": "packages/process/process/src/types.ts" + }, + { + "doc": "docs/core-data-structures/process.md", + "symbol": "ProcessOutcome", + "source": "packages/process/process/src/types.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 16803b157f..dc284e9b21 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -72,6 +72,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, 'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' }, 'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' }, + 'packages/process/process': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' }, + 'packages/process/process-local': { kind: 'indirect', reason: 'The manager backend delegates model rendering to consumer seams such as the bash executor family.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, 'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' }, 'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index a449a34c4e..2f73278c55 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -54,6 +54,7 @@ "./packages/prompt/*/src/invariant.ts", "./packages/llm/*/src/invariant.ts", "./packages/bash/*/src/invariant.ts", + "./packages/process/*/src/invariant.ts", "./packages/code-runtime/*/src/invariant.ts", "./packages/fs/*/src/invariant.ts", "./packages/skill/*/src/invariant.ts", @@ -123,6 +124,7 @@ "./packages/llm/*/src", "./packages/bash/*/src", "./packages/pty/*/src", + "./packages/process/*/src", "./packages/code-runtime/*/src", "./packages/fs/*/src", "./packages/lsp/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 1aab67964a..c4bdc74fe1 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -81,6 +81,8 @@ { "path": "./packages/llm/llm-retry" }, { "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/examples/cli-demo" }, + { "path": "./packages/process/process" }, + { "path": "./packages/process/process-local" }, { "path": "./packages/bash/bash" }, { "path": "./packages/pty/pty" }, { "path": "./packages/pty/pty-local" }, diff --git a/vitest.config.ts b/vitest.config.ts index deb2460f11..0f2b25f627 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,7 +40,7 @@ const testIncludes = [ // that worker threads cannot isolate reliably under aggregate gate contention. // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ - 'packages/bash/bash-local/tests/run.spec.ts', + 'packages/process/process-local/tests/spawn.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', 'packages/ui/app-boot/tests/app-boot.spec.ts', From e20545c9eeda8ac2e5418146aa784a263b7b659d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:20:45 +0800 Subject: [PATCH 08/70] fix(tasks): fail loud when the abstract seam is mounted directly Review finding (Codex round 1): abstract erases at runtime and @deepseek-ai/dsh-tasks used to be the mountable registry, so a stale composition row would register a ctx.tasks with no method implementations and fail far from the misconfiguration. The seam constructor now rejects direct mounts with a load-time pointer at dsh-tasks-local; the seam suite pins the fence, the Agent Note cost paragraph records the actual behavior, and the stale tool-pty README requirement line names the implementation package. --- ...6-07-06-timeout-deadline-library.i18n.yaml | 4 +- .../2026-07-26-process-manager-seam.i18n.yaml | 6 +++ .../2026-07-26-process-manager-seam.zh.md | 38 +++++++++++++++++++ .../2026-07-26-task-registry-seam.i18n.yaml | 4 +- .../2026-07-26-task-registry-seam.md | 2 +- .../2026-07-26-task-registry-seam.zh.md | 2 +- docs/core-data-structures/bash.i18n.yaml | 4 +- packages/pty/tool-pty/README.md | 2 +- 8 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index 6d14977e92..9047815e3c 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml @@ -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 -2026-07-06-timeout-deadline-library.md: 11d4b8cd48dd345d2324b63e01bd726f12d846b4 -2026-07-06-timeout-deadline-library.zh.md: 334914c689adf54a654c5907395c29ceeeb50891 +2026-07-06-timeout-deadline-library.md: b459a7479e1d133d40c0f65464740611f3c59fcb +2026-07-06-timeout-deadline-library.zh.md: e79ac6d7b618b6dde6a487e2f3b21c180b3ef137 diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml new file mode 100644 index 0000000000..48bca24b09 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml @@ -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 +2026-07-26-process-manager-seam.md: 215725951792d23053c93da19c619c08393af1b9 +2026-07-26-process-manager-seam.zh.md: 268486de2f94d491529620941a6e00d90cb50cf0 diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md new file mode 100644 index 0000000000..268486de2f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 进程管理器是 bash 执行器之下的独立 seam(`dsh-process` / `dsh-process-local`) + +Status: implemented + +[English](2026-07-26-process-manager-seam.md) | 中文 + +## 问题 + +`dsh-bash-local` 原先把两项因不同原因而变化的能力捆绑在一起:*运行一条 bash 命令*(命令默认值补全、超时分类、对模型友好的终端环境、bash 工具所渲染的 stdout/stderr 合并)与*运行并管理一个子进程*(detached 进程组、附带 spill 文件的有界尾部保留输出、凭据清除与 `DSH_*` 合并次序、SIGTERM→宽限期→SIGKILL 升级、先终止再等待退出的 dispose(资源释放))。进程这一半(`run.ts`)约占整个包(package)的一半,却没有属于自己的 seam:未来的非 shell 运行器(直接执行 argv 的执行器、worker 监督器)将不得不重新实现这套机制,或者探入 bash 内部;而共享的 `DSH_*`/`CollectedOutput` 词汇则存放在一个名字承诺 shell 语义的包里。这种捆绑还把后台进程的存续期系在执行器的 fiber 上:重载 bash 执行器会杀死每一个存活的后台进程。这一点不同于兄弟的[任务注册表](2026-07-26-task-registry-seam.md):后者的注册存续期刻意长于生产方 fiber。 + +## 决策 + +新的 `process/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: + +- **`@deepseek-ai/dsh-process`(接口)**——拥有 `ctx.processes` 的抽象 `ProcessManager`(仅一个方法:`spawn(spec): ProcessHandle`),以及共享词汇:完全显式的 `ProcessSpawnSpec`(argv、cwd、按流划分的上限、spill 上限、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `ProcessHandle`、刻意不含超时/取消分类的 `ProcessOutcome`,以及 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。 +- **`@deepseek-ai/dsh-process-local`(实现)**——`LocalProcessManager`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、带双通道 `DSH_*` 合并的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 +- **`dsh-bash-local`(消费方)**——`inject: ['processes']`;把每个解析后的 `BashExecSpec` 映射为一个 `ProcessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。 +- **`dsh-bash`(seam)**——把迁走的词汇从 `dsh-process` 重导出,因此没有任何 bash 消费方需要改动导入;`BashExecRequest`/`BashExecSpec`/`BashProcess` 与沙箱事实仍归 bash 所有。 + +如今,每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-process-local`:CLI(命令行界面)、各示例、Python 捆绑运行时、create-sdk 的 bash 功能资源,以及各内联测试配置。 + +后台进程的存续期从执行器移到了管理器:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(管理器的 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.processes` 上。**作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 在其唯一真实的消费方家族上得到验证后交付;其余调用点已在 seam README 中列为暂缓工作。 + +**改把 `run_in_background`/任务语义放进进程 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。进程 seam 位于 bash 执行器*之下*,而不是与任务注册表并列。 + +**把 `ENV_OVERRIDES`(TERM=dumb、PAGER=cat 等)移入管理器。**否决:通用进程管理器不得把终端呈现策略强加给非终端消费方;凭据清除与 `DSH_*` 通道规则是安全与身份不变式,予以保留,但终端友好性是 bash 工具自己的选择,经普通 env 通道表达,而调用方的显式条目在该通道中依旧优先。 + +## 后果 + +换来的是:「运行并管理一个进程」成为一项具备标准三包形态的可替换能力(消费方起步就有两个:`bash-local`、`bash-sandbox`);容器化或远程进程后端可以直接接入,而不触碰 bash 语义;共享的 `DSH_*`/输出词汇有了一个不带 shell 含义的归属;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。spawn 管道测试套件整体迁至 `dsh-process-local`(现以 argv 为基础,外加 argv 校验与管理器生命周期/dispose 套件);执行器测试套件如今对着真实管理器固定 bash 所有的各层(分类、合并、spawn 失败提示、归管理器所有的存续期)。 + +代价是:多出一对包,而且凡加载 bash 执行器之处都多一行组合配置。若某次启动加载了执行器却没有加载管理器,`ctx.bash` 会因等待 `ctx.processes` 而保持挂起(标准的服务缺失行为)。迁移词汇的重导出让 `dsh-bash` 的导入继续可用,但也意味着两个包如今命名同一批类型;进程 seam 是所有者,bash seam 则记录这层重导出。spawn 失败提示经由读取路径变为单次交付,而旧管道曾把它保留在 stderr 缓冲区里,供重复的 `readFrom(0)` 读取;这一点可以接受,因为 bash 的后台读取路径本就是消费游标,该提示能到达唯一存在的那个读取方。 diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml index 409bc30c12..530e12edae 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml @@ -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 -2026-07-26-task-registry-seam.md: b785eb75a632503def10fad583f6a68477cffef6 -2026-07-26-task-registry-seam.zh.md: bfb733a5e1060c9bfe2acc6c4769aa47443d0c9e +2026-07-26-task-registry-seam.md: d550b5b081a7980cceddd3c1eb65c3a9a175906f +2026-07-26-task-registry-seam.zh.md: 1088465b908fd905900aa11479a48632fff3fe6f diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md index b785eb75a6..d550b5b081 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md @@ -32,4 +32,4 @@ The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` st Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling package implementing eight abstract methods, and no producer, control surface, or `TaskKindMap` extender changes when one lands. The seam README states the contract; the implementation README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the seam keeps a stub-subclass test pinning registration under `ctx.tasks` and single-service duplication behavior, plus the probe-based invariant suite. -Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the implementation package — a boot that loads only `@deepseek-ai/dsh-tasks` gets a pending `ctx.tasks` and producers fail with the standard missing-service behavior rather than a bespoke message. The misconfiguration diagnostics naming `dsh-tasks-local` accept staleness if a different backend becomes the recommended default. +Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the implementation package. `abstract` erases at runtime and this package name used to be the mountable registry, so the seam constructor fails loudly when mounted directly — a stale composition row gets "load an implementation such as @deepseek-ai/dsh-tasks-local" at load time instead of a half-registered `ctx.tasks` failing far from the misconfiguration. The misconfiguration diagnostics naming `dsh-tasks-local` accept staleness if a different backend becomes the recommended default. diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md index bfb733a5e1..1088465b90 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md @@ -32,4 +32,4 @@ Status: implemented 换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的兄弟包,这样的注册表落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。seam 包的 README 陈述契约;生命周期簿记方面的事实归实现包的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;seam 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 -代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。若某次启动只加载 `@deepseek-ai/dsh-tasks`,`ctx.tasks` 将保持挂起,生产方会按标准的服务缺失行为失败,而不会收到一条专门定制的消息。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这一点已被接受。 +代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名实现包。`abstract` 在运行时会被擦除,而这个包名过去正是可挂载的具体注册表,因此 seam 的构造函数在被直接挂载时会响亮失败——一条过期的组合配置行会在加载时得到「load an implementation such as @deepseek-ai/dsh-tasks-local」,而不是一个方法残缺的 `ctx.tasks` 在远离错误配置处才失败。若日后另一个后端成为推荐的默认选择,点名 `dsh-tasks-local` 的配置错误诊断信息将随之陈旧;这一点已被接受。 diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index 98855cdc0c..0111aa680d 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.i18n.yaml @@ -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 -bash.md: 35cf2061588907dde41123efb01e453eb9cc929d -bash.zh.md: 0cfeb9e1a858f7057e720215c41a757588751122 +bash.md: e092bfb1bf1aa23f7eeeda41a16a1625d1698201 +bash.zh.md: 7ae60b9f38e6ca5c72e349e141132ae6ec72913d diff --git a/packages/pty/tool-pty/README.md b/packages/pty/tool-pty/README.md index f4f1e7af7e..b16cb271f1 100644 --- a/packages/pty/tool-pty/README.md +++ b/packages/pty/tool-pty/README.md @@ -66,4 +66,4 @@ Append-only; new results follow the reusable request prefix. ## Known Limitations and Deferred Work - No named key sequence, TUI, BEL, resize, auto-start, or cross-agent sharing schema is exposed. -- Background mode requires both `@deepseek-ai/dsh-tasks` and its model-facing control surface. +- Background mode requires both `@deepseek-ai/dsh-tasks-local` and the model-facing control surface from `@deepseek-ai/dsh-tool-tasks`. From 13ce23db4bb3fc089a0a1e17f007af64b33f38ab Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:27:33 +0800 Subject: [PATCH 09/70] docs(process): final translation pass on the process-seam note zh counterpart --- .../architecture/2026-07-26-process-manager-seam.i18n.yaml | 2 +- .../architecture/2026-07-26-process-manager-seam.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml index 48bca24b09..fc1952eaa6 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-26-process-manager-seam.md: 215725951792d23053c93da19c619c08393af1b9 -2026-07-26-process-manager-seam.zh.md: 268486de2f94d491529620941a6e00d90cb50cf0 +2026-07-26-process-manager-seam.zh.md: 03062f33d3d5fbafa1ce0b9bc3e723d10572cfd4 diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md index 268486de2f..03062f33d3 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`dsh-bash-local` 原先把两项因不同原因而变化的能力捆绑在一起:*运行一条 bash 命令*(命令默认值补全、超时分类、对模型友好的终端环境、bash 工具所渲染的 stdout/stderr 合并)与*运行并管理一个子进程*(detached 进程组、附带 spill 文件的有界尾部保留输出、凭据清除与 `DSH_*` 合并次序、SIGTERM→宽限期→SIGKILL 升级、先终止再等待退出的 dispose(资源释放))。进程这一半(`run.ts`)约占整个包(package)的一半,却没有属于自己的 seam:未来的非 shell 运行器(直接执行 argv 的执行器、worker 监督器)将不得不重新实现这套机制,或者探入 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。 ## 决策 From fb0d4ba56434f01cd71efb1e9685702fcd3ae708 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:54:42 +0800 Subject: [PATCH 10/70] fix(process): close review gaps from Codex round 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mount LocalProcessManager in the sandbox e2e compositions (bwrap/landlock/ seatbelt + the spine multi-project e2e) and add the spine demo's dsh-process-local devDependency, so SandboxBashExecutor's new inject resolves when those suites are enabled. - Extend the Windows test/coverage skip to packages/process/* — the POSIX process-group suite moved there from packages/bash. - Update the stale disposal contract: the bash seam JSDoc, BashProcess JSDoc, and core bash doc (en+zh) now state that composition teardown (the process manager's disposal) owns kill-and-await, and an executor-only reload leaves background processes running. - Record ctx.processes in the architecture capability table and extension map (en+zh) and the root AGENTS.md layout tree; reword the timeout-library note so it describes where the plumbing and classification live today. --- AGENTS.md | 1 + docs/architecture.i18n.yaml | 4 ++-- docs/architecture.md | 3 ++- docs/architecture.zh.md | 3 ++- docs/cordis-catalog/services.md | 4 ++-- docs/core-data-structures/bash.i18n.yaml | 4 ++-- docs/core-data-structures/bash.md | 7 ++++--- docs/core-data-structures/bash.zh.md | 7 ++++--- packages/bash/bash-sandbox/tests/bwrap.e2e.ts | 2 ++ packages/bash/bash-sandbox/tests/landlock.e2e.ts | 2 ++ packages/bash/bash-sandbox/tests/seatbelt.e2e.ts | 2 ++ packages/bash/bash/src/index.ts | 5 ++++- packages/bash/bash/src/types.ts | 5 +++-- packages/examples/agent-spine-demo/package.json | 1 + .../agent-spine-demo/tests/multi-project-sandbox.e2e.ts | 2 ++ pnpm-lock.yaml | 3 +++ vitest.config.ts | 1 + 17 files changed, 39 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 64259784fb..abcd3e8709 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// prompt/ workspace instructions llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools + process/ child-process manager seam + local process-group impl pty/ persistent PTY seam/backend/tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools lsp/ language-server seam + local stdio provider + model-facing lsp tool diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 1798b46908..5293177942 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 -architecture.md: b426891c0483f42a64b597632cf1871aff79ca2d -architecture.zh.md: 13feefb6854e79ddee38602902d325a789fd7744 +architecture.md: ca2d1c70342fcef35deca7e247863511fb7c35b8 +architecture.zh.md: c3107b23f96497a5ab2184d660d085f3e8a5e527 diff --git a/docs/architecture.md b/docs/architecture.md index b426891c04..ca2d1c7034 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,6 +28,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | +| `ctx.processes` | [`process/`](../packages/process/README.md) | managed child-process groups under the bash executors | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | @@ -177,7 +178,7 @@ New behavior attaches to a documented extension point; a loop change updates thi |---|---| | Add a model provider | register an adapter on `ctx.llm` | | Add a model-facing capability | register on `ctx.tools`; schemas enter prompt assembly | -| Add shell execution | implement and register a `ctx.bash` backend | +| Add shell execution | implement and register a `ctx.bash` backend (the local one spawns through `ctx.processes`) | | Add persistent terminal execution | register a `ctx.pty` backend and `dsh-tool-pty` | | Add a human command | register on `ctx.commands`; adapters discover and dispatch it without a model turn | | Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 13feefb685..c3107b23f9 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -28,6 +28,7 @@ | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力和会话表面压力 | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 | +| `ctx.processes` | [`process/`](../packages/process/README.md) | bash 执行器之下受管理的子进程组 | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同一执行环境内的进程限制(argv 包装、逐调用策略) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 | @@ -177,7 +178,7 @@ forever: |---|---| | 添加模型提供方 | 在 `ctx.llm` 上注册适配器 | | 添加面向模型的功能 | 在 `ctx.tools` 上注册;schema 进入提示词组装流程 | -| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端 | +| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端(本地后端通过 `ctx.processes` 生成进程) | | 添加持久化终端执行 | 注册 `ctx.pty` 后端和 `dsh-tool-pty` | | 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派该命令 | | 添加后台工作 | 在 `ctx.tasks` 上注册;通用 `task_*` 工具负责收集或停止 | diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6ea9919713..b2229130bc 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -257,7 +257,7 @@ Implementations must honor these semantics: - run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a BashRunResult. - start returns immediately; no timeout applies to background processes. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on stderr. - BashProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files. -- Disposal kills all running background processes and awaits their exit. +- A still-running background process is stopped and awaited when its owning composition tears down. With the process-manager seam that boundary is `ctx.processes` disposal, so a background process survives an executor-only reload. ```ts cordis-catalog /** @@ -286,7 +286,7 @@ abstract start(spec: BashExecSpec): BashProcess Types: [BashExecRequest](../core-data-structures/bash.md) · [BashExecSpec](../core-data-structures/bash.md) · [BashProcess](../core-data-structures/bash.md) · [BashRunResult](../core-data-structures/bash.md) -Source: [`packages/bash/bash/src/index.ts:48`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:51`](../../packages/bash/bash/src/index.ts) ## `ctx.bashEnv` — `BashEnvRegistry` diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index 0111aa680d..00a77885e8 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.i18n.yaml @@ -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 -bash.md: e092bfb1bf1aa23f7eeeda41a16a1625d1698201 -bash.zh.md: 7ae60b9f38e6ca5c72e349e141132ae6ec72913d +bash.md: 0c92addf4778fa780cd432444549bfc36eeb763c +bash.zh.md: cd9d94055627937d03107ead5a4ffaeaa945d187 diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index e092bfb1bf..0c92addf47 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -170,8 +170,9 @@ One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (o ```ts type-equiv /** * A background process handle returned by {@link BashExecutor.start}. It is the - * only access path; buffered output remains readable after exit. Executor - * disposal kills running processes and awaits {@link done}. + * only access path; buffered output remains readable after exit. Composition + * teardown (the process manager's disposal) kills running processes and + * awaits {@link done}; an executor-only reload leaves them running. */ interface BashProcess { /** Process lifecycle state (settled exactly once). */ @@ -216,4 +217,4 @@ interface BashProcessRead { ## The service -`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns process groups, timeout/abort handling, bounded collectors, spill files, credential scrubbing, and disposal quiescence. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md). +`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; process groups, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [process manager](process.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md). diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index 7ae60b9f38..cd9d940556 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -170,8 +170,9 @@ interface BashSandboxInfo { ```ts type-equiv /** * A background process handle returned by {@link BashExecutor.start}. It is the - * only access path; buffered output remains readable after exit. Executor - * disposal kills running processes and awaits {@link done}. + * only access path; buffered output remains readable after exit. Composition + * teardown (the process manager's disposal) kills running processes and + * awaits {@link done}; an executor-only reload leaves them running. */ interface BashProcess { /** Process lifecycle state (settled exactly once). */ @@ -216,4 +217,4 @@ interface BashProcessRead { ## 服务 -`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有进程组、超时/中止处理、有界收集器、spill 文件、凭据清除以及 dispose(资源释放)后完全停稳。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 +`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;进程组、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[进程管理器](process.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 diff --git a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts index 87bcffe9df..ad6ecab6f1 100644 --- a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts @@ -9,6 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' /** * Keyless integration of the real provider and executor through public run/start paths. With @@ -42,6 +43,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index 3ce944b07c..aac9b766e8 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -9,6 +9,7 @@ import { launcherPath } from 'node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' /** * KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap @@ -47,6 +48,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false } await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 6c212ee546..87e76d95e1 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -9,6 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' /** * Keyless macOS integration of the real provider and executor through public run/start paths. @@ -41,6 +42,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' } await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index d9eedee052..ac03e73a91 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -43,7 +43,10 @@ declare module 'cordis' { * failures settle as `killed` with the error on stderr. * - {@link BashProcess.readOutput} is incremental: consecutive reads never * repeat output. Lossy reads report truncation and available spill files. - * - Disposal kills all running background processes and awaits their exit. + * - A still-running background process is stopped and awaited when its + * owning composition tears down. With the process-manager seam that + * boundary is `ctx.processes` disposal, so a background process survives + * an executor-only reload. */ export abstract class BashExecutor extends Service { constructor(ctx: Context) { diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 5928687e93..4fdb11288e 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -153,8 +153,9 @@ export interface BashProcessRead { /** * A background process handle returned by {@link BashExecutor.start}. It is the - * only access path; buffered output remains readable after exit. Executor - * disposal kills running processes and awaits {@link done}. + * only access path; buffered output remains readable after exit. Composition + * teardown (the process manager's disposal) kills running processes and + * awaits {@link done}; an executor-only reload leaves them running. */ export interface BashProcess { /** Process lifecycle state (settled exactly once). */ diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 923a9aace6..6ecca27aed 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-process-local": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts index 11f5421bf9..f22947eb3b 100644 --- a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts +++ b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts @@ -5,6 +5,7 @@ import { basename, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalProcessManager from '@deepseek-ai/dsh-process-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' import { CallId } from '@deepseek-ai/dsh-llm' @@ -52,6 +53,7 @@ beforeEach(async () => { ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot }) + await ctx.plugin(LocalProcessManager) await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 }) await ctx.plugin(SandboxedFileSystem, { cwd: fallbackRoot }) await ctx.plugin(agentSpine, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0471ffc91..340d47c7fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1658,6 +1658,9 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths + '@deepseek-ai/dsh-process-local': + specifier: workspace:^ + version: link:../../process/process-local '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../sandbox/sandbox-local diff --git a/vitest.config.ts b/vitest.config.ts index 0f2b25f627..a0379a325b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,7 @@ const windowsUnsupportedPackages = process.platform === 'win32' ? [ 'packages/bash/*', 'packages/hooks/*', + 'packages/process/*', 'packages/pty/pty-local', 'packages/sandbox/sandbox-local', 'packages/sdk/create-sdk', From 65cd40e76494686b891aee932a570e63879e9ddc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:03:34 +0800 Subject: [PATCH 11/70] docs(process): unambiguous past-tense in the timeout-library note Codex round-2 (B): the retargeted bullet read as though spawn.ts carries the full timeout plumbing today. Split the historical claim (run.ts, pre-library) from the current split (spawn.ts reacts to aborts; bash-local owns the fused deadline and classification), en+zh, pair re-recorded. --- .../architecture/2026-07-06-timeout-deadline-library.i18n.yaml | 2 +- .../architecture/2026-07-06-timeout-deadline-library.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index 9047815e3c..7a6c6be383 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-06-timeout-deadline-library.md: b459a7479e1d133d40c0f65464740611f3c59fcb -2026-07-06-timeout-deadline-library.zh.md: e79ac6d7b618b6dde6a487e2f3b21c180b3ef137 +2026-07-06-timeout-deadline-library.zh.md: 5b2c0049705787c27435dcdca2c3847eadd27963 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index e79ac6d7b6..5b2c004970 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -8,7 +8,7 @@ Status: implemented 超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。 -- **bash**([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。 +- **bash**([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——今天位于 [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts)——只响应中止;[packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 - **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。 - **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。) From bd462ff2b59e152712952f36edd84f35a74306c4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:07:53 +0800 Subject: [PATCH 12/70] docs(process): apply the timeout-note clarification to the English side too The previous commit's English edit silently missed (its match pattern targeted already-rewritten text); only the zh side carried the split. Both sides now state it and the pair is re-recorded. --- .../architecture/2026-07-06-timeout-deadline-library.i18n.yaml | 2 +- .../architecture/2026-07-06-timeout-deadline-library.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index 7a6c6be383..86b29e2a7a 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml @@ -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 -2026-07-06-timeout-deadline-library.md: b459a7479e1d133d40c0f65464740611f3c59fcb +2026-07-06-timeout-deadline-library.md: 8a21918097de105e7ebd5c94135add96e976b171 2026-07-06-timeout-deadline-library.zh.md: 5b2c0049705787c27435dcdca2c3847eadd27963 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index b459a7479e..8a21918097 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -8,7 +8,7 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md) Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden. -- **bash** ([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts)) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. +- **bash** (then in `packages/bash/bash-local/src/run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — today [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts) — only reacts to aborts; [packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. - **web_fetch** ([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`. - **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.) From 2d2bf871920a2981dd77d16897136ec23844fc6b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:17:18 +0800 Subject: [PATCH 13/70] =?UTF-8?q?fix(process):=20CI=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20built-bin=20closures=20and=20gate-safe=20path=20pro?= =?UTF-8?q?se?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The acp-demo and cli-demo built-bin e2e consumers link a pruned package closure; add process/process and process/process-local so the built bins boot now that bash-local injects ctx.processes. - verify-package-paths scans backticked packages/* references, so the timeout-note's historical mention of the deleted run.ts path is reworded (en+zh, both sides now carry the same historical-vs-current split; the earlier zh edit had silently missed its match); pair re-recorded. --- .../2026-07-06-timeout-deadline-library.i18n.yaml | 4 ++-- .../architecture/2026-07-06-timeout-deadline-library.md | 2 +- .../architecture/2026-07-06-timeout-deadline-library.zh.md | 2 +- packages/examples/acp-demo/tests/built-bin.e2e.ts | 2 +- packages/examples/cli-demo/tests/built-bin.e2e.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index 86b29e2a7a..b213b86ba1 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml @@ -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 -2026-07-06-timeout-deadline-library.md: 8a21918097de105e7ebd5c94135add96e976b171 -2026-07-06-timeout-deadline-library.zh.md: 5b2c0049705787c27435dcdca2c3847eadd27963 +2026-07-06-timeout-deadline-library.md: 8e739b111644aaa00e2cba9f6de803327de90e26 +2026-07-06-timeout-deadline-library.zh.md: 60d6c60b396b91b0504c7e36d1465787e8b6ae66 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index 8a21918097..8e739b1116 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -8,7 +8,7 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md) Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden. -- **bash** (then in `packages/bash/bash-local/src/run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — today [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts) — only reacts to aborts; [packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. +- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — today [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts) — only reacts to aborts; [packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. - **web_fetch** ([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`. - **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.) diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index 5b2c004970..60d6c60b39 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -8,7 +8,7 @@ Status: implemented 超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。 -- **bash**([packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——今天位于 [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts)——只响应中止;[packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 +- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——今天位于 [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts)——只响应中止;[packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 - **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。 - **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。) diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 3c101f14b5..464196433e 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -32,7 +32,7 @@ const decompress = promisify(zstdDecompress) const dshPackages = [ 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', - 'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot', + 'bash/bash-local', 'bash/tool-bash', 'process/process', 'process/process-local', 'context/workspace-context', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl', 'acp/acp', 'examples/acp-demo', 'util/paths', diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 0dcca62291..25169948e0 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -23,7 +23,7 @@ const decompress = promisify(zstdDecompress) const dshPackages = [ 'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', - 'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot', + 'bash/bash-local', 'bash/tool-bash', 'process/process', 'process/process-local', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl', 'context/workspace-context', From fc566119a728c17066cb458a61da1c0e1bb010c7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:43:14 +0800 Subject: [PATCH 14/70] refactor(subprocess): rename the process seam to subprocess and address review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback (tianyicui): 'process' is a poor service name. The family is now packages/subprocess/ — @deepseek-ai/dsh-subprocess (ctx.subprocess, abstract SubprocessService, Subprocess* vocabulary) and @deepseek-ai/dsh-subprocess-local (LocalSubprocessService) — renamed throughout code, compositions, docs (en+zh, pairs re-recorded), catalogs, and gates. 'subprocess' is the precise term for managed OS children (the Python-stdlib sense), avoids colliding with Node's global process object, and reads as one system beside dsh-subagent-subprocess. ds-review-bot findings addressed: - kill() on a settled handle is now a no-op (no signal to a possibly-reused pgid, no referenced grace timer delaying exit); pinned by a spy test. - The moved DshEnvironmentKey/DshEnvironment/CollectedOutput types get drift-checked type-equiv blocks on the new subprocess.md page, restoring their manifest registration. - subprocess.md is registered in the core.md sub-page index (en+zh). --- ...6-07-06-timeout-deadline-library.i18n.yaml | 4 +- .../2026-07-06-timeout-deadline-library.md | 2 +- .../2026-07-06-timeout-deadline-library.zh.md | 2 +- .../2026-07-26-process-manager-seam.md | 38 ------ ...l => 2026-07-26-subprocess-seam.i18n.yaml} | 4 +- .../2026-07-26-subprocess-seam.md | 38 ++++++ ...zh.md => 2026-07-26-subprocess-seam.zh.md} | 20 +-- apps/cli/cordis.yml | 4 +- apps/cli/package.json | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 4 +- docs/architecture.zh.md | 4 +- docs/capability-seams.md | 12 +- docs/config-catalog.md | 8 +- docs/cordis-catalog/services.md | 54 ++++---- docs/core-data-structures/bash.i18n.yaml | 4 +- docs/core-data-structures/bash.md | 10 +- docs/core-data-structures/bash.zh.md | 10 +- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 1 + docs/core-data-structures/core.zh.md | 1 + .../{process.md => subprocess.md} | 62 ++++++--- docs/module-graph.md | 26 ++-- examples/acp-agent/composition.md | 6 +- examples/acp-agent/cordis.yml | 4 +- examples/cordis-agent/composition.md | 6 +- examples/cordis-agent/cordis.yml | 4 +- examples/headless-agent/composition.md | 6 +- examples/headless-agent/cordis.yml | 4 +- .../semantic-checkpoint.cordis.snapshot.yml | 4 +- .../headless-agent/tests/code-mode.e2e.ts | 6 +- .../tests/fixtures/goal-domain/cordis.yml | 4 +- .../tests/fixtures/time-context.cordis.yml | 4 +- examples/headless-agent/tests/harness.ts | 4 +- examples/jsonrpc-agent/cordis.yml | 4 +- examples/package.json | 2 +- examples/tui-agent/composition.md | 6 +- examples/tui-agent/cordis.yml | 4 +- .../tests/fixtures/tui-scripted.cordis.yml | 4 +- examples/tui-agent/tests/tui.snapshot.ts | 4 +- packages/README.md | 2 +- packages/bash/README.md | 4 +- packages/bash/bash-local/README.md | 14 +- packages/bash/bash-local/package.json | 6 +- packages/bash/bash-local/src/index.ts | 24 ++-- .../bash/bash-local/tests/executor.spec.ts | 22 ++-- packages/bash/bash-local/tsconfig.json | 2 +- packages/bash/bash-sandbox/package.json | 2 +- packages/bash/bash-sandbox/src/index.ts | 4 +- packages/bash/bash-sandbox/tests/bwrap.e2e.ts | 4 +- .../bash/bash-sandbox/tests/landlock.e2e.ts | 4 +- .../bash/bash-sandbox/tests/sandbox.spec.ts | 6 +- .../bash/bash-sandbox/tests/seatbelt.e2e.ts | 4 +- packages/bash/bash/package.json | 4 +- packages/bash/bash/src/index.ts | 4 +- packages/bash/bash/src/types.ts | 10 +- packages/bash/bash/tsconfig.json | 2 +- packages/bash/tool-bash/package.json | 2 +- .../bash/tool-bash/tests/integration.spec.ts | 4 +- packages/bash/tool-bash/tests/tools.spec.ts | 22 ++-- .../cordis/tool-cordis/src/api-catalog.ts | 52 ++------ .../examples/acp-demo/tests/built-bin.e2e.ts | 4 +- .../examples/acp-demo/tests/load-path.e2e.ts | 4 +- .../examples/agent-spine-demo/package.json | 2 +- .../tests/multi-project-sandbox.e2e.ts | 4 +- .../examples/cli-demo/tests/built-bin.e2e.ts | 4 +- packages/fs/tool-fs-search/package.json | 2 +- .../tool-fs-search/tests/integration.spec.ts | 4 +- packages/hooks/hooks-claude/package.json | 2 +- .../hooks/hooks-claude/tests/bridge.spec.ts | 8 +- .../hooks-claude/tests/coverage-cases.ts | 10 +- packages/hooks/hooks-codex/package.json | 2 +- .../hooks/hooks-codex/tests/bridge.spec.ts | 8 +- .../hooks/hooks-codex/tests/coverage-cases.ts | 8 +- packages/process/README.md | 10 -- .../sdk/helper/src/features/builtin/index.ts | 2 +- .../subagent-inprocess/src/invariant.ts | 2 +- packages/subagent/subagent-spawn/package.json | 2 +- .../subagent/subagent-spawn/tests/harness.ts | 4 +- .../subagent-subprocess/src/invariant.ts | 2 +- packages/subprocess/README.md | 10 ++ .../subprocess-local}/README.md | 8 +- .../subprocess-local}/package.json | 8 +- .../subprocess-local}/src/index.ts | 20 +-- .../subprocess-local}/src/invariant.ts | 8 +- .../subprocess-local}/src/spawn.ts | 24 ++-- .../subprocess-local/tests/local.spec.ts} | 36 +++--- .../subprocess-local}/tests/spawn.spec.ts | 27 +++- .../subprocess-local}/tsconfig.json | 2 +- .../subprocess}/README.md | 10 +- .../subprocess}/package.json | 4 +- .../subprocess}/src/index.ts | 34 ++--- .../subprocess}/src/invariant.ts | 8 +- .../subprocess}/src/types.ts | 28 ++-- .../subprocess}/tests/service.spec.ts | 26 ++-- .../subprocess}/tsconfig.json | 0 pnpm-lock.yaml | 122 +++++++++--------- python/sdk-runtime/package.json | 4 +- .../runtime/cordis.yml | 4 +- python/sdk/tests/test_bundled_runtime.py | 4 +- scripts/gen-cordis-catalog.ts | 12 +- scripts/gen-doc-graphs.ts | 4 +- scripts/gen-tool-catalog.ts | 4 +- scripts/type-equiv.manifest.json | 45 ++++--- .../verify-package-readme-model-experience.ts | 4 +- tsconfig.base.json | 4 +- tsconfig.host.json | 4 +- vitest.config.ts | 4 +- 108 files changed, 587 insertions(+), 557 deletions(-) delete mode 100644 .agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md rename .agents/notes/implemented/architecture/{2026-07-26-process-manager-seam.i18n.yaml => 2026-07-26-subprocess-seam.i18n.yaml} (65%) create mode 100644 .agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md rename .agents/notes/implemented/architecture/{2026-07-26-process-manager-seam.zh.md => 2026-07-26-subprocess-seam.zh.md} (50%) rename docs/core-data-structures/{process.md => subprocess.md} (59%) delete mode 100644 packages/process/README.md create mode 100644 packages/subprocess/README.md rename packages/{process/process-local => subprocess/subprocess-local}/README.md (78%) rename packages/{process/process-local => subprocess/subprocess-local}/package.json (83%) rename packages/{process/process-local => subprocess/subprocess-local}/src/index.ts (69%) rename packages/{process/process-local => subprocess/subprocess-local}/src/invariant.ts (77%) rename packages/{process/process-local => subprocess/subprocess-local}/src/spawn.ts (92%) rename packages/{process/process-local/tests/manager.spec.ts => subprocess/subprocess-local/tests/local.spec.ts} (57%) rename packages/{process/process-local => subprocess/subprocess-local}/tests/spawn.spec.ts (95%) rename packages/{process/process-local => subprocess/subprocess-local}/tsconfig.json (92%) rename packages/{process/process => subprocess/subprocess}/README.md (61%) rename packages/{process/process => subprocess/subprocess}/package.json (77%) rename packages/{process/process => subprocess/subprocess}/src/index.ts (65%) rename packages/{process/process => subprocess/subprocess}/src/invariant.ts (73%) rename packages/{process/process => subprocess/subprocess}/src/types.ts (87%) rename packages/{process/process => subprocess/subprocess}/tests/service.spec.ts (60%) rename packages/{process/process => subprocess/subprocess}/tsconfig.json (100%) diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index b213b86ba1..471cf9f92d 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml @@ -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 -2026-07-06-timeout-deadline-library.md: 8e739b111644aaa00e2cba9f6de803327de90e26 -2026-07-06-timeout-deadline-library.zh.md: 60d6c60b396b91b0504c7e36d1465787e8b6ae66 +2026-07-06-timeout-deadline-library.md: 63463a76a65743436d4e78479800c19e257a42de +2026-07-06-timeout-deadline-library.zh.md: c3d3cdf1c63813fc24c10727e42d326142f3f4de diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index 8e739b1116..63463a76a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -8,7 +8,7 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md) Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden. -- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — today [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts) — only reacts to aborts; [packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. +- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — today [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts) — only reacts to aborts; [packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. - **web_fetch** ([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`. - **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.) diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index 60d6c60b39..c3d3cdf1c6 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -8,7 +8,7 @@ Status: implemented 超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。 -- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——今天位于 [packages/process/process-local/src/spawn.ts](../../../../packages/process/process-local/src/spawn.ts)——只响应中止;[packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 +- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——今天位于 [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts)——只响应中止;[packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 - **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。 - **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。) diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md b/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md deleted file mode 100644 index 2157259517..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: The process manager is its own seam under the bash executors (`dsh-process` / `dsh-process-local`) - -Status: implemented - -English | [中文](2026-07-26-process-manager-seam.zh.md) - -## 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](2026-07-26-task-registry-seam.md), whose registrations deliberately outlive producer fibers. - -## Decision - -A new `process/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it: - -- **`@deepseek-ai/dsh-process` (interface)** — the abstract `ProcessManager` owning `ctx.processes` with one method, `spawn(spec): ProcessHandle`, and the shared vocabulary: the fully-explicit `ProcessSpawnSpec` (argv, cwd, per-stream caps, spill cap, 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), `ProcessHandle` with non-consuming offset-based readers, `ProcessOutcome` with deliberately no timeout/cancel classification, and the `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted. -- **`@deepseek-ai/dsh-process-local` (implementation)** — `LocalProcessManager` over the former `run.ts` plumbing (`spawn.ts`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the two-channel `DSH_*` 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 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. -- **`dsh-bash-local` (consumer)** — `inject: ['processes']`; maps each resolved `BashExecSpec` onto a `ProcessSpawnSpec` (`['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-process`, so no bash consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned. - -Every composition that loads a bash executor now also loads `@deepseek-ai/dsh-process-local` (CLI, examples, python bundled runtime, create-sdk's bash feature resources, inline test configs). - -Background-process lifetime moved from the executor to the manager: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the manager'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 manager 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](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.processes` in the same change.** Rejected as scope creep with real design risk: 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 ships proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule; the others are named as deferred work in the seam README. - -**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 manager.** Rejected: a generic process manager 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-process-local` (argv-based, plus argv-validation and manager lifecycle/disposal suites); the executor suite now pins the bash-owned layers (classification, merge, spawn-failure note, manager-owned lifetime) against the real manager. - -Cost: one more package pair and one more composition row everywhere a bash executor loads — a boot that loads an executor without the manager leaves `ctx.bash` pending on `ctx.processes` (standard missing-service behavior). The moved-vocabulary re-exports keep `dsh-bash` imports working but mean two packages now name the same types; the process 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. diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml similarity index 65% rename from .agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml rename to .agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index fc1952eaa6..a0ea989893 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml @@ -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 -2026-07-26-process-manager-seam.md: 215725951792d23053c93da19c619c08393af1b9 -2026-07-26-process-manager-seam.zh.md: 03062f33d3d5fbafa1ce0b9bc3e723d10572cfd4 +2026-07-26-subprocess-seam.md: cd7b18c209af0830e339abfe01aa91dc493e15da +2026-07-26-subprocess-seam.zh.md: 685d9797a7456b4edf95853b253dc97f3426c902 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md new file mode 100644 index 0000000000..cd7b18c209 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -0,0 +1,38 @@ +# Agent Note: The subprocess service is its own seam under the bash executors (`dsh-subprocess` / `dsh-subprocess-local`) + +Status: implemented + +English | [中文](2026-07-26-subprocess-seam.zh.md) + +## 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](2026-07-26-task-registry-seam.md), whose registrations deliberately outlive producer fibers. + +## Decision + +A new `process/` 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 caps, spill cap, 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 `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`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the two-channel `DSH_*` 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 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. +- **`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. + +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](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: 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 ships proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule; the others are named as deferred work in the seam README. + +**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. diff --git a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md similarity index 50% rename from .agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md rename to .agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md index 03062f33d3..685d9797a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -1,8 +1,8 @@ -# Agent Note: 进程管理器是 bash 执行器之下的独立 seam(`dsh-process` / `dsh-process-local`) +# Agent Note: 进程管理器是 bash 执行器之下的独立 seam(`dsh-subprocess` / `dsh-subprocess-local`) Status: implemented -[English](2026-07-26-process-manager-seam.md) | 中文 +[English](2026-07-26-subprocess-seam.md) | 中文 ## 问题 @@ -12,12 +12,12 @@ Status: implemented 新的 `process/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: -- **`@deepseek-ai/dsh-process`(接口)**——拥有 `ctx.processes` 的抽象 `ProcessManager`(仅一个方法:`spawn(spec): ProcessHandle`),以及共享词汇:完全显式的 `ProcessSpawnSpec`(argv、cwd、按流划分的上限、spill 上限、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `ProcessHandle`、刻意不含超时/取消分类的 `ProcessOutcome`,以及 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。 -- **`@deepseek-ai/dsh-process-local`(实现)**——`LocalProcessManager`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、带双通道 `DSH_*` 合并的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 -- **`dsh-bash-local`(消费方)**——`inject: ['processes']`;把每个解析后的 `BashExecSpec` 映射为一个 `ProcessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。 -- **`dsh-bash`(seam)**——把迁走的词汇从 `dsh-process` 重导出,因此没有任何 bash 消费方需要改动导入;`BashExecRequest`/`BashExecSpec`/`BashProcess` 与沙箱事实仍归 bash 所有。 +- **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、按流划分的上限、spill 上限、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `SubprocessHandle`、刻意不含超时/取消分类的 `SubprocessOutcome`,以及 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。 +- **`@deepseek-ai/dsh-subprocess-local`(实现)**——`LocalSubprocessService`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、带双通道 `DSH_*` 合并的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 +- **`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-process-local`:CLI(命令行界面)、各示例、Python 捆绑运行时、create-sdk 的 bash 功能资源,以及各内联测试配置。 +如今,每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-subprocess-local`:CLI(命令行界面)、各示例、Python 捆绑运行时、create-sdk 的 bash 功能资源,以及各内联测试配置。 后台进程的存续期从执行器移到了管理器:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(管理器的 dispose)仍是先终止再等待退出的边界。一条行为 seam 随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,管理器会 reject `done`,且不缓冲任何内容),因此执行器把 `spawn failed: …` 提示注入恰好一个 `readOutput()` 增量。 @@ -25,7 +25,7 @@ Status: implemented **把进程管道留在 `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.processes` 上。**作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 在其唯一真实的消费方家族上得到验证后交付;其余调用点已在 seam README 中列为暂缓工作。 +**在同一变更中把仓库其余 spawn 调用点(lsp-local、pty-local、subagent-subprocess、sdk package-manager、test-support 各启动器)迁到 `ctx.subprocess` 上。**作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 在其唯一真实的消费方家族上得到验证后交付;其余调用点已在 seam README 中列为暂缓工作。 **改把 `run_in_background`/任务语义放进进程 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。进程 seam 位于 bash 执行器*之下*,而不是与任务注册表并列。 @@ -33,6 +33,6 @@ Status: implemented ## 后果 -换来的是:「运行并管理一个进程」成为一项具备标准三包形态的可替换能力(消费方起步就有两个:`bash-local`、`bash-sandbox`);容器化或远程进程后端可以直接接入,而不触碰 bash 语义;共享的 `DSH_*`/输出词汇有了一个不带 shell 含义的归属;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。spawn 管道测试套件整体迁至 `dsh-process-local`(现以 argv 为基础,外加 argv 校验与管理器生命周期/dispose 套件);执行器测试套件如今对着真实管理器固定 bash 所有的各层(分类、合并、spawn 失败提示、归管理器所有的存续期)。 +换来的是:「运行并管理一个进程」成为一项具备标准三包形态的可替换能力(消费方起步就有两个:`bash-local`、`bash-sandbox`);容器化或远程进程后端可以直接接入,而不触碰 bash 语义;共享的 `DSH_*`/输出词汇有了一个不带 shell 含义的归属;后台进程也能在执行器重载后存活,与任务注册表的存续期模型一致。spawn 管道测试套件整体迁至 `dsh-subprocess-local`(现以 argv 为基础,外加 argv 校验与管理器生命周期/dispose 套件);执行器测试套件如今对着真实管理器固定 bash 所有的各层(分类、合并、spawn 失败提示、归管理器所有的存续期)。 -代价是:多出一对包,而且凡加载 bash 执行器之处都多一行组合配置。若某次启动加载了执行器却没有加载管理器,`ctx.bash` 会因等待 `ctx.processes` 而保持挂起(标准的服务缺失行为)。迁移词汇的重导出让 `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 的后台读取路径本就是消费游标,该提示能到达唯一存在的那个读取方。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 5521c09b65..c2ab296385 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -89,8 +89,8 @@ name: '@deepseek-ai/dsh-workspace' # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash-local name: '@deepseek-ai/dsh-bash-local' diff --git a/apps/cli/package.json b/apps/cli/package.json index 7ef006c924..e21b51e836 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -42,7 +42,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 5293177942..50abfe37ff 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 -architecture.md: ca2d1c70342fcef35deca7e247863511fb7c35b8 -architecture.zh.md: c3107b23f96497a5ab2184d660d085f3e8a5e527 +architecture.md: a1e169a9ef7717c6889f5a1aff53af984de565f4 +architecture.zh.md: 025d3b7343b36dca91cca9ebe6323628cb0702f9 diff --git a/docs/architecture.md b/docs/architecture.md index ca2d1c7034..a1e169a9ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | -| `ctx.processes` | [`process/`](../packages/process/README.md) | managed child-process groups under the bash executors | +| `ctx.subprocess` | [`process/`](../packages/subprocess/README.md) | managed child-process groups under the bash executors | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | @@ -178,7 +178,7 @@ New behavior attaches to a documented extension point; a loop change updates thi |---|---| | Add a model provider | register an adapter on `ctx.llm` | | Add a model-facing capability | register on `ctx.tools`; schemas enter prompt assembly | -| Add shell execution | implement and register a `ctx.bash` backend (the local one spawns through `ctx.processes`) | +| Add shell execution | implement and register a `ctx.bash` backend (the local one spawns through `ctx.subprocess`) | | Add persistent terminal execution | register a `ctx.pty` backend and `dsh-tool-pty` | | Add a human command | register on `ctx.commands`; adapters discover and dispatch it without a model turn | | Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index c3107b23f9..025d3b7343 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -28,7 +28,7 @@ | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力和会话表面压力 | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 | -| `ctx.processes` | [`process/`](../packages/process/README.md) | bash 执行器之下受管理的子进程组 | +| `ctx.subprocess` | [`process/`](../packages/subprocess/README.md) | bash 执行器之下受管理的子进程组 | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同一执行环境内的进程限制(argv 包装、逐调用策略) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 | @@ -178,7 +178,7 @@ forever: |---|---| | 添加模型提供方 | 在 `ctx.llm` 上注册适配器 | | 添加面向模型的功能 | 在 `ctx.tools` 上注册;schema 进入提示词组装流程 | -| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端(本地后端通过 `ctx.processes` 生成进程) | +| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端(本地后端通过 `ctx.subprocess` 生成进程) | | 添加持久化终端执行 | 注册 `ctx.pty` 后端和 `dsh-tool-pty` | | 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派该命令 | | 添加后台工作 | 在 `ctx.tasks` 上注册;通用 `task_*` 工具负责收集或停止 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 0fef36c63e..8e0169458f 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -83,7 +83,7 @@ flowchart LR pkg_goal["goal"] svc_goals["ctx.goals
Same-session goal domain"] pkg_process["process"] - svc_processes["ctx.processes
Process manager seam"] + svc_subprocess["ctx.subprocess
Process manager seam"] pkg_process_local["process-local"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] @@ -167,8 +167,8 @@ flowchart LR pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode - pkg_process --> svc_processes - pkg_process_local --> svc_processes + pkg_process --> svc_subprocess + pkg_process_local --> svc_subprocess pkg_pty --> svc_pty pkg_pty_local --> svc_pty pkg_sandbox --> svc_sandbox @@ -239,8 +239,6 @@ flowchart LR svc_invariants --> pkg_session svc_llm --> pkg_agent_loop svc_llm --> pkg_compact_basic - svc_processes --> pkg_bash_local - svc_processes --> pkg_bash_sandbox svc_pty --> pkg_tool_pty svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_pty_local @@ -270,6 +268,8 @@ flowchart LR svc_storageDomain --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent + svc_subprocess --> pkg_bash_local + svc_subprocess --> pkg_bash_sandbox svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs svc_systemPrompt --> pkg_tool_pty @@ -324,7 +324,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | -| `ctx.processes` | `seam` | [`process`](../packages/process/process) | [`process-local`](../packages/process/process-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | - | The bash executors spawn their process groups through ctx.processes; the manager owns group lifetime, bounded spill-backed output, and kill escalation. | +| `ctx.subprocess` | `seam` | `process` | `process-local` | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | - | The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 322f5c70d3..a21b11378d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -192,7 +192,7 @@ Source: [`packages/examples/agent-spine-demo/src/index.ts:87`](../packages/examp ## `@deepseek-ai/dsh-bash-local` -Requires: `processes` +Requires: `subprocess` ```ts config-catalog /** Plugin config (all optional — `static Config` supplies the defaults). */ @@ -216,7 +216,7 @@ Source: [`packages/bash/bash-local/src/index.ts:39`](../packages/bash/bash-local ## `@deepseek-ai/dsh-bash-sandbox` -Requires: `processes` · `sandbox` · `sandboxPolicy` +Requires: `subprocess` · `sandbox` · `sandboxPolicy` ```ts config-catalog /** @@ -2055,12 +2055,12 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) -- `@deepseek-ai/dsh-process-local` ([`packages/process/process-local/src/index.ts`](../packages/process/process-local/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) +- `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) - `@deepseek-ai/dsh-tasks-local` ([`packages/tasks/tasks-local/src/index.ts`](../packages/tasks/tasks-local/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)) @@ -2076,11 +2076,11 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) -- `@deepseek-ai/dsh-process` — abstract `ProcessManager` ([`packages/process/process/src/index.ts`](../packages/process/process/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) +- `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) - `@deepseek-ai/dsh-workflow` — abstract `WorkflowService` ([`packages/workflow/workflow/src/index.ts`](../packages/workflow/workflow/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b2229130bc..4108332e43 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -257,7 +257,7 @@ Implementations must honor these semantics: - run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a BashRunResult. - start returns immediately; no timeout applies to background processes. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on stderr. - BashProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files. -- A still-running background process is stopped and awaited when its owning composition tears down. With the process-manager seam that boundary is `ctx.processes` disposal, so a background process survives an executor-only reload. +- A still-running background process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a background process survives an executor-only reload. ```ts cordis-catalog /** @@ -315,7 +315,7 @@ collect(execution: ToolExecution): DshEnvironment list(): BashEnvVariableInfo[] ``` -Types: [DshEnvironment](../core-data-structures/process.md) · [ToolExecution](../core-data-structures/tools.md) +Types: [DshEnvironment](../core-data-structures/subprocess.md) · [ToolExecution](../core-data-structures/tools.md) Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-bash/src/index.ts) @@ -829,31 +829,6 @@ Types: [Agent](../core-data-structures/core.md) Source: [`packages/plan/plan-mode/src/index.ts:141`](../../packages/plan/plan-mode/src/index.ts) -## `ctx.processes` — `ProcessManager` (abstract seam) - -Abstract process manager. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.processes` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). - -Implementations must honor these semantics: - -- spawn returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures. -- Output readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. -- ProcessHandle.kill and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole process group. -- Disposal kills all still-running managed processes and awaits their exit. - -```ts cordis-catalog -/** - * Start one managed child process from a fully-specified spec; this seam - * applies no defaults. - * @param spec - argv, directory, limits, grace, cancellation, and environment. - * @returns the live process handle (readers, kill, outcome promise). - */ -abstract spawn(spec: ProcessSpawnSpec): ProcessHandle -``` - -Types: [ProcessHandle](../core-data-structures/process.md) · [ProcessSpawnSpec](../core-data-structures/process.md) - -Source: [`packages/process/process/src/index.ts:48`](../../packages/process/process/src/index.ts) - ## `ctx.pty` — `PtyService` In-process registry for replaceable PTY backends and exact-Agent sessions. @@ -1584,6 +1559,31 @@ Types: [SubagentProvider](../core-data-structures/subagent.md) · [SubagentRun]( Source: [`packages/subagent/subagent/src/index.ts:180`](../../packages/subagent/subagent/src/index.ts) +## `ctx.subprocess` — `SubprocessService` (abstract seam) + +Abstract subprocess service. Subclass, implement spawn, and load the subclass as a plugin — it registers as `ctx.subprocess` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). + +Implementations must honor these semantics: + +- spawn returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures. +- Output readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. +- SubprocessHandle.kill and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole process group. +- Disposal kills all still-running managed processes and awaits their exit. + +```ts cordis-catalog +/** + * Start one managed child process from a fully-specified spec; this seam + * applies no defaults. + * @param spec - argv, directory, limits, grace, cancellation, and environment. + * @returns the live process handle (readers, kill, outcome promise). + */ +abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle +``` + +Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) + +Source: [`packages/subprocess/subprocess/src/index.ts:48`](../../packages/subprocess/subprocess/src/index.ts) + ## `ctx.systemPrompt` — `SystemPrompt` Registry service for the prompt inputs assembled before each model step. diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index 00a77885e8..fba715d163 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.i18n.yaml @@ -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 -bash.md: 0c92addf4778fa780cd432444549bfc36eeb763c -bash.zh.md: cd9d94055627937d03107ead5a4ffaeaa945d187 +bash.md: 4ea0ace7e4af8cda3ff5bf1b4fc672f6425e5396 +bash.zh.md: 6642c85134748bceaa4a7783742a63da8b6acabd diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 0c92addf47..4ea0ace7e4 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -2,13 +2,13 @@ English | [中文](bash.zh.md) -The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle. Raw process-group mechanics live behind the [process-manager seam](process.md). +The bash execution seam is split across interface ([dsh-bash](../../packages/bash/bash), `ctx.bash`), implementations ([dsh-bash-local](../../packages/bash/bash-local) and [dsh-bash-sandbox](../../packages/bash/bash-sandbox)), and consumer ([dsh-tool-bash](../../packages/bash/tool-bash), the `bash` schema). Generic background-task ids, ownership, and controls live in [tasks.md](tasks.md); this seam returns a task-free process handle. Raw process-group mechanics live behind the [subprocess seam](subprocess.md). Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) ## Managed shell environment namespace -`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; the process manager removes inherited `DSH_*` names before merging the current snapshot. The `DshEnvironmentKey`/`DshEnvironment` vocabulary is owned by the [process-manager seam](process.md) and re-exported by `dsh-bash`. +`DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; the subprocess service removes inherited `DSH_*` names before merging the current snapshot. The `DshEnvironmentKey`/`DshEnvironment` vocabulary is owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`. ## Request vs. spec: the `resolve()` split @@ -135,7 +135,7 @@ interface BashRunResult { } ``` -Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The shape is owned by the [process-manager seam](process.md) and re-exported by `dsh-bash`. +Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info; when truncated, `text` is the **tail** and the complete stream spills to a private file. The shape is owned by the [subprocess seam](subprocess.md) and re-exported by `dsh-bash`. ## File sandbox: `BashSandboxInfo` @@ -171,7 +171,7 @@ One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (o /** * A background process handle returned by {@link BashExecutor.start}. It is the * only access path; buffered output remains readable after exit. Composition - * teardown (the process manager's disposal) kills running processes and + * teardown (the subprocess service's disposal) kills running processes and * awaits {@link done}; an executor-only reload leaves them running. */ interface BashProcess { @@ -217,4 +217,4 @@ interface BashProcessRead { ## The service -`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; process groups, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [process manager](process.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md). +`BashExecutor` owns `resolve`, foreground `run`, background-process `start`, and the `sandboxMode` capability fact. `dsh-bash-local` owns command defaulting, timeout/abort classification, the terminal environment, and the background read merge; process groups, bounded collectors, spill files, credential scrubbing, and disposal quiescence are the [subprocess service](subprocess.md)'s. `dsh-tool-bash` owns model-facing rendering and adapts background handles into the [generic task runtime](tasks.md). diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index cd9d940556..6642c85134 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -2,13 +2,13 @@ [English](bash.md) | 中文 -bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制位于[进程管理器 seam](process.md)之后。 +bash 执行 seam 分为接口([dsh-bash](../../packages/bash/bash),`ctx.bash`)、实现([dsh-bash-local](../../packages/bash/bash-local) 与 [dsh-bash-sandbox](../../packages/bash/bash-sandbox))和消费方([dsh-tool-bash](../../packages/bash/tool-bash),即 `bash` schema)。通用后台任务的 id、所有权与控制位于 [tasks.md](tasks.md);本 seam 返回一个不含任务概念的进程句柄。原始进程组机制位于[进程管理器 seam](subprocess.md)之后。 源码:[`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.ts) ## 受管 shell 环境命名空间 -`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;进程管理器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。`DshEnvironmentKey`/`DshEnvironment` 词汇归[进程管理器 seam](process.md)所有,由 `dsh-bash` 重导出。 +`DSH_*` 变量是归 Harness 所有的子进程事实。面向模型的 bash 工具通过 `ctx.bashEnv` 收集它们,再经由 `BashExecRequest.dshEnv` 传递;进程管理器在合并当前快照之前会移除继承而来的 `DSH_*` 名称。`DshEnvironmentKey`/`DshEnvironment` 词汇归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。 ## 请求与规格:`resolve()` 拆分 @@ -135,7 +135,7 @@ interface BashRunResult { } ``` -每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。该形状归[进程管理器 seam](process.md)所有,由 `dsh-bash` 重导出。 +每个流是一个 `CollectedOutput`:(可能被截断的)文本加恢复信息;截断时,`text` 是**尾部**,完整流溢出到一个私有文件。该形状归[进程管理器 seam](subprocess.md)所有,由 `dsh-bash` 重导出。 ## 文件沙箱:`BashSandboxInfo` @@ -171,7 +171,7 @@ interface BashSandboxInfo { /** * A background process handle returned by {@link BashExecutor.start}. It is the * only access path; buffered output remains readable after exit. Composition - * teardown (the process manager's disposal) kills running processes and + * teardown (the subprocess service's disposal) kills running processes and * awaits {@link done}; an executor-only reload leaves them running. */ interface BashProcess { @@ -217,4 +217,4 @@ interface BashProcessRead { ## 服务 -`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;进程组、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[进程管理器](process.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 +`BashExecutor` 拥有 `resolve`、前台 `run`、后台进程 `start` 以及 `sandboxMode` 能力事实。`dsh-bash-local` 拥有命令默认值补全、超时/中止分类、终端环境以及后台读取合并;进程组、有界收集器、spill 文件、凭据清除与 dispose(资源释放)后完全停稳归[进程管理器](subprocess.md)所有。`dsh-tool-bash` 拥有面向模型的渲染,并将后台句柄适配到[通用任务运行时](tasks.md)。 diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 47310dd09b..3ff88fcca2 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -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 -core.md: 781267cccdb5bbda33e5be6a9e807fdbe47dbc83 -core.zh.md: d0f67983b98b0cf679a8e599a5f8ab3c64490dd0 +core.md: cdbf8f2f7a4484986abe56511698ae3f4c2096b4 +core.zh.md: 2afd9b02135a0cc54a48ca9404be4d55f5b8c64a diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 781267cccd..cdbf8f2f7a 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -31,6 +31,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles | +| [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary | | [pty.md](pty.md) | persistent terminal ids, backend/session contracts, send readiness, bounded reads, and owner-visible snapshots | | [sandbox.md](sandbox.md) | per-session policy resolution and the process-confinement seam: file-effect modes, execution/provider policies, `ConfinedArgv`, enforcement and fail-closed errors | | [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy | diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index d0f67983b9..2afd9b0213 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -31,6 +31,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数 | [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 | | [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计与 answerer 契约 | | [bash.md](bash.md) | bash 执行器 seam:`BashExecRequest`/`Spec`、`BashRunResult`、后台 `BashProcess` 句柄 | +| [subprocess.md](subprocess.md) | 子进程 seam:完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 | | [pty.md](pty.md) | 持久化终端 ID、后端/会话契约、发送就绪状态、有界读取与 owner 可见快照 | | [sandbox.md](sandbox.md) | 每会话策略解析与进程约束 seam:文件效果模式、执行/提供方策略、`ConfinedArgv`、强制执行与故障关闭错误 | | [code-runtime.md](code-runtime.md) | 代码执行 seam:`CodeRunRequest`/`Result`、绑定命名空间、捕获日志、`CodeRunFailure` 分类体系 | diff --git a/docs/core-data-structures/process.md b/docs/core-data-structures/subprocess.md similarity index 59% rename from docs/core-data-structures/process.md rename to docs/core-data-structures/subprocess.md index 93e7a73ad6..6e7cea3990 100644 --- a/docs/core-data-structures/process.md +++ b/docs/core-data-structures/subprocess.md @@ -1,21 +1,47 @@ -# Process Manager +# Subprocess -The child-process manager seam is split across interface ([dsh-process](../../packages/process/process), `ctx.processes`) and implementation ([dsh-process-local](../../packages/process/process-local)); its consumers are other capability seams — today the [bash executor family](bash.md), which passes `['bash', '-c', command]` argv and owns every default. This seam owns the managed `DSH_*` environment namespace and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports them so bash consumers keep one import root. +The subprocess seam is split across interface ([dsh-subprocess](../../packages/subprocess/subprocess), `ctx.subprocess`) and implementation ([dsh-subprocess-local](../../packages/subprocess/subprocess-local)); its consumers are other capability seams — today the [bash executor family](bash.md), which passes `['bash', '-c', command]` argv and owns every default. This seam owns the managed `DSH_*` environment namespace and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports them so bash consumers keep one import root. -Source: [`packages/process/process/src/types.ts`](../../packages/process/process/src/types.ts) +Source: [`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts) + +## Managed environment namespace and captured output + +`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before merging the caller's snapshot, and each captured stream reports its truncation and spill-recovery state through `CollectedOutput`. + +```ts type-equiv +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ +type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` +``` + +```ts type-equiv +/** Trusted DeepSeek Harness variables for one child-process execution. */ +type DshEnvironment = Readonly> +``` + +```ts type-equiv +/** One captured stream: the (possibly truncated) text plus recovery info. */ +interface CollectedOutput { + /** Collected text — the TAIL of the stream when truncated. */ + text: string + /** True when bytes were dropped from `text`. */ + truncated: boolean + /** Path to a file holding the COMPLETE stream, when truncated and available. */ + spillPath?: string +} +``` ## 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. +The seam applies no defaults: every limit and directory is explicit on the spec, so the caller's own config — not a hidden subprocess-service default — decides them. `argv` is never shell-interpreted. ```ts type-equiv /** * 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 + * subprocess-service default — decides them (the `dsh-bash` request/spec split * is the owning template). */ -interface ProcessSpawnSpec { +interface SubprocessSpawnSpec { /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ argv: readonly string[] /** Working directory for the child. */ @@ -62,15 +88,15 @@ A spawn returns a live handle immediately. Output readers take whole-stream byte * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL * escalation; buffered output remains readable after exit. */ -interface ProcessHandle { +interface SubprocessHandle { /** Process id (group leader); -1 when the spawn itself failed. */ readonly pid: number /** Live stdout reader (also readable after exit). */ - readonly stdout: ProcessOutputReader + readonly stdout: SubprocessOutputReader /** Live stderr reader (also readable after exit). */ - readonly stderr: ProcessOutputReader + readonly stderr: SubprocessOutputReader /** Resolves when the process closes; rejects only for spawn-level failures. */ - readonly done: Promise + readonly done: Promise /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ kill(): void } @@ -82,7 +108,7 @@ interface ProcessHandle { * whole-stream byte coordinates owned by the caller, so independent readers * cannot consume one another's output. */ -interface ProcessOutputReader { +interface SubprocessOutputReader { /** * Read everything captured since `fromByte`. When that offset has slid out * of the in-memory tail window the read is `lossy` — it returns the whole @@ -90,13 +116,13 @@ interface ProcessOutputReader { * @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 + readFrom(fromByte: number): SubprocessOutputRead } ``` ```ts type-equiv -/** One incremental {@link ProcessOutputReader.readFrom} read. */ -interface ProcessOutputRead { +/** One incremental {@link SubprocessOutputReader.readFrom} read. */ +interface SubprocessOutputRead { /** Stream text from the requested offset (the whole retained tail when lossy). */ text: string /** Whole-stream offset to resume from on the next read. */ @@ -110,15 +136,15 @@ interface ProcessOutputRead { ## 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). +`done` reports raw exit facts. The service 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). ```ts type-equiv /** * Raw outcome of one closed process. Deliberately carries NO timeout or - * cancellation classification: the manager kills on abort but does not decide + * cancellation classification: the service kills on abort but does not decide * why — the caller reads the signal it owns to classify causes. */ -interface ProcessOutcome { +interface SubprocessOutcome { /** Exit code; null when the process died from a signal. */ exitCode: number | null /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ @@ -130,4 +156,4 @@ interface ProcessOutcome { ## Service behavior -The abstract [`ProcessManager`](../../packages/process/process/src/index.ts) seam defines `spawn` only; [`LocalProcessManager`](../../packages/process/process-local/src/index.ts) is the local implementation (detached groups, tail-keep spill-backed collection, credential scrub, kill-and-join disposal). See [`dsh-process`](../../packages/process/process/README.md) for the seam contract and [`dsh-process-local`](../../packages/process/process-local/README.md) for the mechanics. +The abstract [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam defines `spawn` only; [`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) is the local implementation (detached groups, tail-keep spill-backed collection, credential scrub, kill-and-join disposal). See [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md) for the seam contract and [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md) for the mechanics. diff --git a/docs/module-graph.md b/docs/module-graph.md index 10e6ab65a6..f310cac386 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -184,10 +184,6 @@ flowchart TD subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end - subgraph group_process["packages/process"] - pkg_process["process"] - pkg_process_local["process-local"] - end subgraph group_pty["packages/pty"] pkg_pty["pty"] pkg_pty_local["pty-local"] @@ -209,6 +205,10 @@ flowchart TD pkg_storage_json["storage-json"] pkg_storage_sqlite["storage-sqlite"] end + subgraph group_subprocess["packages/subprocess"] + pkg_subprocess["subprocess"] + pkg_subprocess_local["subprocess-local"] + end subgraph group_tasks["packages/tasks"] pkg_tasks["tasks"] pkg_tasks_local["tasks-local"] @@ -247,8 +247,8 @@ flowchart TD pkg_jsonrpc_demo --> pkg_invariants pkg_host_apiproxy --> pkg_invariants pkg_host_webserver --> pkg_invariants - pkg_process --> pkg_invariants pkg_storage --> pkg_invariants + pkg_subprocess --> pkg_invariants pkg_llm --> pkg_brand pkg_llm --> pkg_invariants pkg_client_connection --> pkg_host_webserver @@ -271,8 +271,6 @@ flowchart TD pkg_client_ui_workspace --> pkg_client_ui_primitives pkg_client_ui_workspace --> pkg_client_ui_slots pkg_client_ui_workspace --> pkg_invariants - pkg_process_local --> pkg_invariants - pkg_process_local --> pkg_process pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_telemetry --> pkg_brand @@ -284,6 +282,8 @@ flowchart TD pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_invariants pkg_storage_sqlite --> pkg_storage + pkg_subprocess_local --> pkg_invariants + pkg_subprocess_local --> pkg_subprocess pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout @@ -314,8 +314,8 @@ flowchart TD pkg_agent --> pkg_session pkg_agent --> pkg_system_prompt pkg_bash --> pkg_invariants - pkg_bash --> pkg_process pkg_bash --> pkg_sandbox + pkg_bash --> pkg_subprocess pkg_fs --> pkg_brand pkg_fs --> pkg_invariants pkg_fs --> pkg_llm @@ -379,7 +379,7 @@ flowchart TD pkg_goal --> pkg_session pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_invariants - pkg_bash_local --> pkg_process + pkg_bash_local --> pkg_subprocess pkg_bash_local --> pkg_timeout pkg_fs_local --> pkg_fs pkg_fs_local --> pkg_invariants @@ -837,8 +837,8 @@ flowchart TD | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | -| [`process`](../packages/process/process) | `process` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | @@ -846,12 +846,12 @@ flowchart TD | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`process-local`](../packages/process/process-local) | `process` | [`invariants`](../packages/support/invariants), [`process`](../packages/process/process) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -861,7 +861,7 @@ flowchart TD | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | -| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`process`](../packages/process/process), [`sandbox`](../packages/sandbox/sandbox) | +| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -880,7 +880,7 @@ flowchart TD | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | -| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`process`](../packages/process/process), [`timeout`](../packages/util/timeout) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) | diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index e4660ed8da..b4a3236920 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -14,8 +14,8 @@ flowchart LR cfg --> plugin_acp_sandbox plugin_acp_sandbox_policy["sandbox-policy
@deepseek-ai/dsh-sandbox-policy"] cfg --> plugin_acp_sandbox_policy - plugin_acp_processes["processes
@deepseek-ai/dsh-process-local"] - cfg --> plugin_acp_processes + plugin_acp_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_acp_subprocess plugin_acp_bash["bash
@deepseek-ai/dsh-bash-sandbox"] cfg --> plugin_acp_bash plugin_acp_approval["approval
@deepseek-ai/dsh-user-approval"] @@ -70,7 +70,7 @@ flowchart LR | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `sandbox` | `@deepseek-ai/dsh-sandbox-local` | | `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` | -| `processes` | `@deepseek-ai/dsh-process-local` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `acp-agent` | `@deepseek-ai/dsh-acp-demo` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 38994b1826..f784972429 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -34,8 +34,8 @@ workspaceRoot: !!js process.cwd() # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-sandbox' diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 78eb186e37..55e7f33b2c 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -12,8 +12,8 @@ flowchart LR cfg --> plugin_cordis_hmr plugin_cordis_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_cordis_llm_deepseek - plugin_cordis_processes["processes
@deepseek-ai/dsh-process-local"] - cfg --> plugin_cordis_processes + plugin_cordis_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_cordis_subprocess plugin_cordis_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_cordis_bash plugin_cordis_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] @@ -41,7 +41,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `processes` | `@deepseek-ai/dsh-process-local` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `web` | `@deepseek-ai/dsh-web` | diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 051c3a5662..369886c9e5 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -26,8 +26,8 @@ # Local bash executor for agent-spine-demo's tool-bash schema — gives the agent an # ordinary tool whose calls make the mounted listeners observably fire. # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 98ef6cd167..53a01260e1 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -10,8 +10,8 @@ flowchart LR cfg["examples/headless-agent
cordis.yml"] plugin_headless_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_headless_llm_deepseek - plugin_headless_processes["processes
@deepseek-ai/dsh-process-local"] - cfg --> plugin_headless_processes + plugin_headless_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_headless_subprocess plugin_headless_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_headless_bash plugin_headless_cli_agent["cli-agent
@deepseek-ai/dsh-cli-demo"] @@ -56,7 +56,7 @@ flowchart LR | Plugin id | Package / module | | --- | --- | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `processes` | `@deepseek-ai/dsh-process-local` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `cli-agent` | `@deepseek-ai/dsh-cli-demo` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 261eaa6d20..896c73469b 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -20,8 +20,8 @@ contextWindow: 128000 # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml index 66fbca2e6a..432b64eb9f 100644 --- a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml +++ b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml @@ -18,8 +18,8 @@ overrideFile: !!js process.env.DSH_SNAPSHOT_OVERRIDE # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index 6f36b1f8c9..31e029f201 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -13,7 +13,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' @@ -56,7 +56,7 @@ async function codeModeHarness(cwd: string): Promise { await harness.plugin(AgentRegistry) await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek) - await harness.plugin(LocalProcessManager) + await harness.plugin(LocalSubprocessService) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) await harness.plugin(WorkerCodeRuntime, {}) @@ -116,7 +116,7 @@ async function backgroundCodeModeHarness(cwd: string): Promise { const harness = await typedCodeModeHarness() await harness.plugin(LocalTaskService) await harness.plugin(ToolTasks, {}) - await harness.plugin(LocalProcessManager) + await harness.plugin(LocalSubprocessService) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) return harness diff --git a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml index d20c05b785..d66713526e 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml +++ b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml @@ -3,8 +3,8 @@ name: '../cli-mock-llm.ts' # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/fixtures/time-context.cordis.yml b/examples/headless-agent/tests/fixtures/time-context.cordis.yml index 1050d32376..a105652e9c 100644 --- a/examples/headless-agent/tests/fixtures/time-context.cordis.yml +++ b/examples/headless-agent/tests/fixtures/time-context.cordis.yml @@ -3,8 +3,8 @@ name: './time-context-mock-llm.ts' # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index cf5f30aaee..f0cde5c274 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -4,7 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -60,7 +60,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio await ctx.plugin(LlmDeepSeek, options.modelContextWindow === undefined ? {} : { models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }], }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index 7576b06cbf..b1364679f9 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -18,8 +18,8 @@ reasoningEffort: max # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/package.json b/examples/package.json index 183aa03111..237a46df28 100644 --- a/examples/package.json +++ b/examples/package.json @@ -31,7 +31,7 @@ "@deepseek-ai/dsh-lsp": "workspace:*", "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", - "@deepseek-ai/dsh-process-local": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index 9c64a95fbb..c6fc223113 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -12,8 +12,8 @@ flowchart LR cfg --> plugin_tui_hmr plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek - plugin_tui_processes["processes
@deepseek-ai/dsh-process-local"] - cfg --> plugin_tui_processes + plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_tui_subprocess plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_tui_bash plugin_tui_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] @@ -71,7 +71,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `processes` | `@deepseek-ai/dsh-process-local` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 9a67ce4f77..7c8b03db05 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -22,8 +22,8 @@ # Local executor for the app bundle's bash tool. # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index f25cafc64b..a32bdf987b 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -5,8 +5,8 @@ name: './tui-scripted-llm.ts' # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 795f21a794..8b99bc302b 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -8,7 +8,7 @@ import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' import CommandService from '@deepseek-ai/dsh-commands' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -205,7 +205,7 @@ async function mountScenarioContext( skills: { local: { agentsHome: join(cwd, '.agents') } }, }) await ctx.plugin(TokenMeterService) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' }) await ctx.plugin(FsPolicy) diff --git a/packages/README.md b/packages/README.md index f9707a178d..2c559981a7 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,7 +11,7 @@ Packages live at `packages///`; groups are containers, while names r | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | | [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | -| [`process/`](process/README.md) | Child-process manager capability family: spawn seam + local process-group implementation | Product — stable surface | +| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-group implementation | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface | | [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface | diff --git a/packages/bash/README.md b/packages/bash/README.md index 57b0ffcf9e..b9ec625353 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -4,8 +4,8 @@ The canonical three-package capability seam (see [capability seams](../../.agent | Package | Role | ctx key | |---|---|---| -| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`process/`](../process/README.md) seam) | `ctx.bash` | -| `bash-local/` | Local `BashExecutor` implementation over the [`process/`](../process/README.md) manager (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) | +| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` | +| `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) | | `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) | | `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) | diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 5fb4bd0a39..259c2e4950 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-bash-local -Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-process`](../../process/process/README.md) manager: `LocalBashExecutor` spawns `bash -c ` per call as a managed process group through `ctx.processes`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the process manager's. +Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c ` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's. The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`. @@ -23,10 +23,10 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices: - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. -- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the manager explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-process-local`](../../process/process-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). -- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the manager's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). -- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the manager's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the manager, so it survives executor reloads and dies (killed and joined) with the manager's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. +- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. ## Model Experience @@ -40,7 +40,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`. - **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them. -- **POSIX-only** — the `bash` binary is hardcoded, and the underlying manager's group semantics are POSIX; Windows is unsupported. -- **A background spawn-failure note is single-delivery** — the manager buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. +- **POSIX-only** — the `bash` binary is hardcoded, and the underlying service's group semantics are POSIX; Windows is unsupported. +- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. -Scrub-heuristic and spill-retention caveats live with [`dsh-process-local`](../../process/process-local/README.md), which owns those mechanics. +Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index edfcafabe5..bacc7ac92d 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -29,7 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-process": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -39,8 +39,8 @@ "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-process": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 5cb99b3caa..463e7a96d4 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -1,7 +1,7 @@ /** - * Local implementation of the bash executor seam over the process-manager + * Local implementation of the bash executor seam over the subprocess * seam. Each command runs as `bash -c` in a managed process group spawned - * through `ctx.processes`; this executor owns command defaulting, deadlines + * through `ctx.subprocess`; this executor owns command defaulting, deadlines * and cause classification, the model-friendly terminal environment, and the * model-facing stdout/stderr merge for background reads. Execution policy * belongs in `tools/pre-execute` or a sandboxing executor. @@ -12,7 +12,7 @@ import { Context } from 'cordis' import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' -import type { ProcessSpawnSpec } from '@deepseek-ai/dsh-process' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' /** @@ -20,7 +20,7 @@ import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' * interactive terminal features that would garble tool output (the same set * Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy — * merged into the ordinary env channel, so a trusted caller's own entry still - * wins; the process manager applies its credential scrub independently. + * wins; the subprocess service applies its credential scrub independently. */ export const ENV_OVERRIDES = { NO_COLOR: '1', @@ -61,14 +61,14 @@ function assertPositiveFinite(name: string, value: number): void { } /** - * Local bash executor over `ctx.processes`. Bounded output, spill files, and - * process-group SIGTERM→SIGKILL escalation are the process manager's + * Local bash executor over `ctx.subprocess`. Bounded output, spill files, and + * process-group SIGTERM→SIGKILL escalation are the subprocess service's * mechanics; this executor supplies their configured budgets per spawn, so a * still-running background process stays managed (killed and joined at * composition teardown) even across an executor reload. */ export class LocalBashExecutor extends BashExecutor { - static inject = ['processes'] + static inject = ['subprocess'] static Config: z = z.object({ cwd: z.string(), @@ -116,7 +116,7 @@ export class LocalBashExecutor extends BashExecutor { stdoutMaxBytes, ...request.signal ? { signal: request.signal } : {}, // Carry stdin/ordinary env/trusted dshEnv through verbatim — optional, - // no config default. The process manager owns the scrub and merge order. + // no config default. The subprocess service owns the scrub and merge order. ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, @@ -129,7 +129,7 @@ export class LocalBashExecutor extends BashExecutor { /** Map one resolved bash spec onto a fully-specified process spawn. */ // XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. - private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): ProcessSpawnSpec { + private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { return { argv: ['bash', '-c', spec.command], cwd: spec.workdir, @@ -147,7 +147,7 @@ export class LocalBashExecutor extends BashExecutor { async run(spec: BashExecSpec): Promise { // One deadline combines timeout and upstream cancellation; disposal clears its timer. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') - const outcome = await this.ctx.processes.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)).done + const outcome = await this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)).done // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined const aborted = d.signal.aborted && !timedOut @@ -156,9 +156,9 @@ export class LocalBashExecutor extends BashExecutor { start(spec: BashExecSpec): BashProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. - const running = this.ctx.processes.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) - // A spawn failure produces no process output, so the manager has nothing + // A spawn failure produces no process output, so the subprocess service has nothing // to buffer; the note is delivered exactly once through the read path. let spawnFailureNote: string | undefined const consumeSpawnFailure = (): string => { diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index aa5eb2fb92..d174b264ab 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -4,15 +4,15 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import type { BashProcess } from '@deepseek-ai/dsh-bash' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) async function setup(config: ConstructorParameters[1] = {}) { const ctx = new Context() - await ctx.plugin(LocalProcessManager) - ;(ctx.processes as LocalProcessManager).internals = { spillDir } + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } // A short kill grace via the REAL config path, so escalation tests stay fast. await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config }) const bash = ctx.bash as LocalBashExecutor @@ -295,11 +295,11 @@ describe('LocalBashExecutor.start (background process handles)', () => { }) }) -describe('process lifecycle ownership (the manager, not the executor)', () => { - it('a background process survives executor-fiber disposal and dies with the process manager', async () => { +describe('process lifecycle ownership (the subprocess service, not the executor)', () => { + it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => { const ctx = new Context() - const managerFiber = await ctx.plugin(LocalProcessManager) - ;(ctx.processes as LocalProcessManager).internals = { spillDir } + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } const executorFiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor @@ -316,17 +316,17 @@ describe('process lifecycle ownership (the manager, not the executor)', () => { expect(proc.status).toBe('running') expect(() => process.kill(pid, 0)).not.toThrow() - // Manager disposal kills the group and AWAITS its exit (no orphans). + // Service disposal kills the group and AWAITS its exit (no orphans). await managerFiber.dispose() expect(() => process.kill(pid, 0)).toThrow() await proc.done expect(proc.status).toBe('killed') }) - it('manager disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => { + it('service disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => { const ctx = new Context() - const managerFiber = await ctx.plugin(LocalProcessManager) - ;(ctx.processes as LocalProcessManager).internals = { spillDir } + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json index ce24865d49..53ccc94926 100644 --- a/packages/bash/bash-local/tsconfig.json +++ b/packages/bash/bash-local/tsconfig.json @@ -27,7 +27,7 @@ "path": "../../bash/bash" }, { - "path": "../../process/process" + "path": "../../subprocess/subprocess" }, { "path": "../../support/invariants" diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index b8737e0e68..0f2240630c 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -38,7 +38,7 @@ "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index 3f0fc0c55d..3945809fa9 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -34,7 +34,7 @@ export type Config = LocalConfig * mode; `result.sandbox` reports the mode and enforcement actually used. */ export class SandboxBashExecutor extends LocalBashExecutor { - static override inject = ['processes', 'sandbox', 'sandboxPolicy'] + static override inject = ['subprocess', 'sandbox', 'sandboxPolicy'] // No own Config: the sandbox default (mode + workspaceRoot) moved to // ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config @@ -128,7 +128,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { * Wrap one shell command via the `ctx.sandbox` provider: hand over the * exact `['bash', '-c', command]` argv this executor would spawn, get back * the confined argv, and re-assemble it into the `exec …` command string - * the inherited spawn path runs (the outer `bash -c` the process manager spawns + * the inherited spawn path runs (the outer `bash -c` the subprocess service spawns * `exec`s into the runner, so no extra shell lingers). Provider errors * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged. */ diff --git a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts index ad6ecab6f1..437078440c 100644 --- a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts @@ -9,7 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * Keyless integration of the real provider and executor through public run/start paths. With @@ -43,7 +43,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index aac9b766e8..0c5cfbe563 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -9,7 +9,7 @@ import { launcherPath } from 'node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap @@ -48,7 +48,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false } await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 3510c71737..90a67999c8 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -15,7 +15,7 @@ import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts' import type { Config } from '@deepseek-ai/dsh-bash-sandbox' @@ -59,8 +59,8 @@ async function setup( ...mode !== undefined ? { mode } : {}, ...workspaceRoot !== undefined ? { workspaceRoot } : {}, }) - await ctx.plugin(LocalProcessManager) - ;(ctx.processes as LocalProcessManager).internals = { spillDir } + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig }) const bash = ctx.bash as SandboxBashExecutor return { ctx, bash, calls } diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 87e76d95e1..62b1569ee7 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -9,7 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * Keyless macOS integration of the real provider and executor through public run/start paths. @@ -42,7 +42,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' } await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 13c31ee2de..b8ff310f01 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -28,13 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-process": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-process": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index ac03e73a91..4f8ae112a9 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -44,8 +44,8 @@ declare module 'cordis' { * - {@link BashProcess.readOutput} is incremental: consecutive reads never * repeat output. Lossy reads report truncation and available spill files. * - A still-running background process is stopped and awaited when its - * owning composition tears down. With the process-manager seam that - * boundary is `ctx.processes` disposal, so a background process survives + * owning composition tears down. With the subprocess seam that + * boundary is `ctx.subprocess` disposal, so a background process survives * an executor-only reload. */ export abstract class BashExecutor extends Service { diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 4fdb11288e..ea153605af 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -2,16 +2,16 @@ * Execution types for the bash executor seam. Background task semantics belong * to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. The * managed-environment and captured-output vocabulary is owned by the - * process-manager seam and re-exported here so bash consumers keep one import + * subprocess seam and re-exported here so bash consumers keep one import * root. * @module dsh-bash/types */ import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' -import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-process' +import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-subprocess' -export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-process' -export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-process' +export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' +export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-subprocess' /** * Sandbox facts for one run, present iff a sandboxing executor handled it. @@ -154,7 +154,7 @@ export interface BashProcessRead { /** * A background process handle returned by {@link BashExecutor.start}. It is the * only access path; buffered output remains readable after exit. Composition - * teardown (the process manager's disposal) kills running processes and + * teardown (the subprocess service's disposal) kills running processes and * awaits {@link done}; an executor-only reload leaves them running. */ export interface BashProcess { diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index ad38e8e3e1..3f611c80e0 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -15,7 +15,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../../process/process" + "path": "../../subprocess/subprocess" }, { "path": "../../sandbox/sandbox" diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index 821e9706c9..c34e1c6e7b 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -52,7 +52,7 @@ "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 1a1d8bb4db..434f53ca2a 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -11,7 +11,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 4b7647c519..15222ee647 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -17,7 +17,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { processOutcome } from '../src/background.ts' @@ -33,8 +33,8 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalProcessManager) - ;(ctx.processes as LocalProcessManager).internals = { spillDir } + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) await ctx.plugin(ToolBash) return ctx @@ -48,8 +48,8 @@ async function setupWithTasks() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) - await ctx.plugin(LocalProcessManager) - ;(ctx.processes as LocalProcessManager).internals = { spillDir } + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) await ctx.plugin(ToolBash) return ctx @@ -278,8 +278,8 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalProcessManager) - ;(ctx.processes as LocalProcessManager).internals = { spillDir } + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' }) @@ -387,7 +387,7 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) const fiber = await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(1) @@ -405,7 +405,7 @@ describe('bash tool', () => { // inject: ['tools', 'bash'] keeps the plugin pending until bash exists. await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(0) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) await new Promise(resolve => setTimeout(resolve, 0)) expect(ctx.tools.schemas()).toHaveLength(1) @@ -417,7 +417,7 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) ToolBash.apply(ctx, {}) const schema = ctx.tools.schemas()[0]! @@ -533,7 +533,7 @@ describe('background execution through the task runtime', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) await ctx.plugin(ToolBash, { enableRunInBackground: false }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a15c306e2f..59b97aedd5 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -426,16 +426,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, - { - key: 'processes', - summary: 'Abstract process manager.', - methods: [ - { - signature: 'abstract spawn(spec: ProcessSpawnSpec): ProcessHandle', - jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, limits, grace, cancellation, and environment.\n * @returns the live process handle (readers, kill, outcome promise).\n */', - }, - ], - }, { key: 'pty', summary: 'In-process registry for replaceable PTY backends and exact-Agent sessions.', @@ -754,6 +744,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'subprocess', + summary: 'Abstract subprocess service.', + methods: [ + { + signature: 'abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle', + jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, limits, grace, cancellation, and environment.\n * @returns the live process handle (readers, kill, outcome promise).\n */', + }, + ], + }, { key: 'systemPrompt', summary: 'Registry service for the prompt inputs assembled before each model step.', @@ -1458,10 +1458,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CodeRunResult', declaration: 'export interface CodeRunResult {\n value?: CodeJsonValue;\n logs: string[];\n error?: CodeRunFailure;\n}', }, - { - name: 'CollectedOutput', - declaration: 'export interface CollectedOutput {\n text: string;\n truncated: boolean;\n spillPath?: string;\n}', - }, { name: 'CommandDefinition', declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', @@ -1558,14 +1554,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DomainTableSpec', declaration: 'export interface DomainTableSpec {\n readonly valueSchema: ZodType;\n readonly __key?: K;\n}', }, - { - name: 'DshEnvironment', - declaration: 'export type DshEnvironment = Readonly>;', - }, - { - name: 'DshEnvironmentKey', - declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;', - }, { name: 'EditGoalRequest', declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}', @@ -1770,26 +1758,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PresetSpec', declaration: 'export interface PresetSpec {\n sandbox: SandboxMode;\n approval: ApprovalPolicy;\n name?: string;\n description?: string;\n}', }, - { - name: 'ProcessHandle', - declaration: 'export interface ProcessHandle {\n readonly pid: number;\n readonly stdout: ProcessOutputReader;\n readonly stderr: ProcessOutputReader;\n readonly done: Promise;\n kill(): void;\n}', - }, - { - name: 'ProcessOutcome', - declaration: 'export interface ProcessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}', - }, - { - name: 'ProcessOutputRead', - declaration: 'export interface ProcessOutputRead {\n text: string;\n nextOffset: number;\n lossy: boolean;\n spillPath?: string;\n}', - }, - { - name: 'ProcessOutputReader', - declaration: 'export interface ProcessOutputReader {\n readFrom(fromByte: number): ProcessOutputRead;\n}', - }, - { - name: 'ProcessSpawnSpec', - declaration: 'export interface ProcessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdoutMaxBytes: number;\n stderrMaxBytes: number;\n maxSpillBytes: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n}', - }, { name: 'PromptAssembly', declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record;\n}', diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 464196433e..a82896ab73 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -94,8 +94,8 @@ async function makeConsumer(): Promise { await writeFile(join(dir, 'cordis.yml'), [ '- id: mock-llm', ' name: \'./mock-llm.mjs\'', - '- id: processes', - ' name: \'@deepseek-ai/dsh-process-local\'', + '- id: subprocess', + ' name: \'@deepseek-ai/dsh-subprocess-local\'', '- id: bash', ' name: \'@deepseek-ai/dsh-bash-local\'', '- id: acp-agent', diff --git a/packages/examples/acp-demo/tests/load-path.e2e.ts b/packages/examples/acp-demo/tests/load-path.e2e.ts index 624db3d97a..8bc14c7330 100644 --- a/packages/examples/acp-demo/tests/load-path.e2e.ts +++ b/packages/examples/acp-demo/tests/load-path.e2e.ts @@ -35,8 +35,8 @@ const CORDIS_YML = ` name: '@deepseek-ai/dsh-llm-deepseek' config: apiKey: !!js process.env.DEEPSEEK_API_KEY -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' - id: acp-agent diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 6ecca27aed..e76db97daf 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -65,7 +65,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts index f22947eb3b..46f3f56dc3 100644 --- a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts +++ b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts @@ -5,7 +5,7 @@ import { basename, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox' import { CallId } from '@deepseek-ai/dsh-llm' @@ -53,7 +53,7 @@ beforeEach(async () => { ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: fallbackRoot }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: fallbackRoot, timeoutMs: 30_000 }) await ctx.plugin(SandboxedFileSystem, { cwd: fallbackRoot }) await ctx.plugin(agentSpine, { diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 25169948e0..6f80c983f3 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -80,8 +80,8 @@ async function makeConsumer(): Promise { await writeFile(join(dir, 'cordis.yml'), [ '- id: mock-llm', " name: './mock-llm.ts'", - '- id: processes', - " name: '@deepseek-ai/dsh-process-local'", + '- id: subprocess', + " name: '@deepseek-ai/dsh-subprocess-local'", '- id: bash', " name: '@deepseek-ai/dsh-bash-local'", '- id: cli-agent', diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index c0abc66a50..bf9cf15aa0 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -44,7 +44,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-retention": "workspace:^", diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index 3ba8e86c36..6be9629460 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -18,7 +18,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' const testToolSignal = new AbortController().signal @@ -62,7 +62,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', () ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd: dir, timeoutMs: 20_000 }) await ctx.plugin(ToolFsSearch) }) diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index bf333718fa..4d52d1f0c5 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -46,7 +46,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 3cf70d569a..1bceaa3529 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -10,7 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -53,7 +53,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) @@ -355,7 +355,7 @@ describe('hooks-claude bridge — load resilience', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -377,7 +377,7 @@ describe('hooks-claude bridge — load resilience', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') }) await fiber.dispose() diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 1470c5f9a9..a773a1a74d 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -10,7 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -42,7 +42,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp await mountAgentLoopTestDependencies(ctx) if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath, ...opts }) ctx.llm.registerAdapter(['mock'], adapter) @@ -361,7 +361,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) // Direct apply with only configPath — bypasses schemastery's defaults, so // the bridge must run on the raw minimal config (the per-hook timeout is @@ -660,7 +660,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the session cwd). - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) @@ -690,7 +690,7 @@ export function defineCoverageCases(group: CoverageGroup): void { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the child session cwd). - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], new MockAdapter([])) diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 472e010a93..fb598b4da3 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -45,7 +45,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index eb34a5c1f8..3e1e3f8f93 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -10,7 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -42,7 +42,7 @@ async function harness(dir: string, adapter: MockAdapter): Promise { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -166,7 +166,7 @@ describe('hooks-codex bridge', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) await fiber.dispose() @@ -189,7 +189,7 @@ describe('hooks-codex bridge', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) ctx.llm.registerAdapter(['mock'], new MockAdapter([])) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 4bfaf4f98a..1c3c8edff4 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -10,7 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -32,7 +32,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp await mountAgentLoopTestDependencies(ctx) if (opts.sessionRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: opts.sessionRoot }) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts }) ctx.llm.registerAdapter(['mock'], adapter) @@ -312,7 +312,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) ctx.logger.warn = warn as never // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. @@ -622,7 +622,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/process/README.md b/packages/process/README.md deleted file mode 100644 index 956f08e3fe..0000000000 --- a/packages/process/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# process/ — child-process manager capability family - -The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [process-manager seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md). - -| Package | ctx key | Role | -|---|---|---| -| [`process`](process/README.md) (`@deepseek-ai/dsh-process`) | `ctx.processes` | The seam: abstract `ProcessManager.spawn(spec)`, the fully-explicit `ProcessSpawnSpec`, `ProcessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary | -| [`process-local`](process-local/README.md) (`@deepseek-ai/dsh-process-local`) | — | The local implementation: detached process groups, tail-keep truncation with bounded private spill files, the credential scrub and `DSH_*` merge order, kill escalation, and kill-and-join disposal | - -The manager owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/sdk/helper/src/features/builtin/index.ts b/packages/sdk/helper/src/features/builtin/index.ts index a6873b75dc..27ba0aa581 100644 --- a/packages/sdk/helper/src/features/builtin/index.ts +++ b/packages/sdk/helper/src/features/builtin/index.ts @@ -33,7 +33,7 @@ export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry mode: 'exclusive', required: true, baseResources: [ - { kind: 'npm-cordis-config-entry', id: 'processes', package: '@deepseek-ai/dsh-process-local' }, + { kind: 'npm-cordis-config-entry', id: 'subprocess', package: '@deepseek-ai/dsh-subprocess-local' }, { kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' }, ], options: [ diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts index 7b8bfc36e2..4a2188dcc8 100644 --- a/packages/subagent/subagent-inprocess/src/invariant.ts +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -10,7 +10,7 @@ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-inprocess' /** Cordis companion plugin name. */ -export const name = 'subagent-inprocess-invariant' +export const name = 'subagent-insubprocess-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index 6ab0d4f45f..647e0b005c 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -41,7 +41,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index 3bebb612c4..afa1d2a1d2 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -3,7 +3,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -28,7 +28,7 @@ export async function spawnHarness(workdir: string): Promise { }) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek) - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(SubagentService) diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts index c273ce5209..5e401cd738 100644 --- a/packages/subagent/subagent-subprocess/src/invariant.ts +++ b/packages/subagent/subagent-subprocess/src/invariant.ts @@ -10,7 +10,7 @@ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-subprocess' /** Cordis companion plugin name. */ -export const name = 'subagent-subprocess-invariant' +export const name = 'subagent-subsubprocess-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md new file mode 100644 index 0000000000..53b295d54e --- /dev/null +++ b/packages/subprocess/README.md @@ -0,0 +1,10 @@ +# subprocess/ — subprocess capability family + +The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). + +| Package | ctx key | Role | +|---|---|---| +| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary | +| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process groups, tail-keep truncation with bounded private spill files, the credential scrub and `DSH_*` merge order, kill escalation, and kill-and-join disposal | + +The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/process/process-local/README.md b/packages/subprocess/subprocess-local/README.md similarity index 78% rename from packages/process/process-local/README.md rename to packages/subprocess/subprocess-local/README.md index 595e42af2f..4bc2a71691 100644 --- a/packages/process/process-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -1,14 +1,14 @@ -# @deepseek-ai/dsh-process-local +# @deepseek-ai/dsh-subprocess-local -Local-subprocess implementation of the [`@deepseek-ai/dsh-process`](../process/README.md) manager seam: `LocalProcessManager` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today). +Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today). ## Behavior (and where it came from) - **Detached process groups with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent). After the leader exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the spawn open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. - **Tail-keep truncation + bounded spill files** — output beyond a stream's cap keeps the in-memory TAIL (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file whose path is reported when available. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). -- **Offset-based reads** — `ProcessHandle` readers return deltas in whole-stream byte coordinates; the manager never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist. -- **Kill-and-join disposal** — the manager retains live handles only so its own disposal can kill every running group and await its exit; settled and spawn-failed handles leave the live set on settlement. +- **Offset-based reads** — `SubprocessHandle` readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist. +- **Kill-and-join disposal** — the service retains live handles only so its own disposal can kill every running group and await its exit; settled and spawn-failed handles leave the live set on settlement. ## Model Experience diff --git a/packages/process/process-local/package.json b/packages/subprocess/subprocess-local/package.json similarity index 83% rename from packages/process/process-local/package.json rename to packages/subprocess/subprocess-local/package.json index 22d51aadff..72ff50c422 100644 --- a/packages/process/process-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-process-local", - "description": "Local-subprocess implementation of the DeepSeek Harness process-manager seam", + "name": "@deepseek-ai/dsh-subprocess-local", + "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", "version": "0.0.1", "private": true, "type": "module", @@ -28,12 +28,12 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-process": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-process": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/process/process-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts similarity index 69% rename from packages/process/process-local/src/index.ts rename to packages/subprocess/subprocess-local/src/index.ts index 6971a58a71..a5256f325b 100644 --- a/packages/process/process-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -1,26 +1,26 @@ /** - * Local-subprocess implementation of the process-manager seam. Each spawn is + * Local-subprocess implementation of the subprocess seam. Each spawn is * a detached process group with bounded, spill-backed output; disposal kills * and joins live groups. It has no config: every limit arrives on the spec, * so the deployment-varying choices stay with the calling seam's config (the * bash executor's, today). - * @module @deepseek-ai/dsh-process-local + * @module @deepseek-ai/dsh-subprocess-local */ import { Context } from 'cordis' -import { ProcessManager } from '@deepseek-ai/dsh-process' -import type { ProcessHandle, ProcessSpawnSpec } from '@deepseek-ai/dsh-process' +import { SubprocessService } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { spawnProcess } from './spawn.ts' import type { SpawnInternals } from './spawn.ts' /** - * Local process manager: detached process groups, tail-keep truncation with + * Local subprocess service: detached process groups, tail-keep truncation with * bounded spill files, credential-scrubbed environment, and group * SIGTERM→grace→SIGKILL escalation. */ -export class LocalProcessManager extends ProcessManager { +export class LocalSubprocessService extends SubprocessService { /** Live handles retained only so disposal can kill and join them. */ - private live = new Set() + private live = new Set() /** Test seam: spill knobs forwarded to spawnProcess. */ internals: SpawnInternals = {} @@ -36,10 +36,10 @@ export class LocalProcessManager extends ProcessManager { } this.live.clear() await Promise.all(pending) - }, 'local process-manager teardown') + }, 'local subprocess teardown') } - spawn(spec: ProcessSpawnSpec): ProcessHandle { + spawn(spec: SubprocessSpawnSpec): SubprocessHandle { const handle = spawnProcess(spec, this.internals) this.live.add(handle) handle.done.then( @@ -50,4 +50,4 @@ export class LocalProcessManager extends ProcessManager { } } -export default LocalProcessManager +export default LocalSubprocessService diff --git a/packages/process/process-local/src/invariant.ts b/packages/subprocess/subprocess-local/src/invariant.ts similarity index 77% rename from packages/process/process-local/src/invariant.ts rename to packages/subprocess/subprocess-local/src/invariant.ts index fa6c0c810e..b15b2dd511 100644 --- a/packages/process/process-local/src/invariant.ts +++ b/packages/subprocess/subprocess-local/src/invariant.ts @@ -1,16 +1,16 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-process-local`. - * @module @deepseek-ai/dsh-process-local/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-subprocess-local`. + * @module @deepseek-ai/dsh-subprocess-local/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-process-local' +const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess-local' /** Cordis companion plugin name. */ -export const name = 'process-local-invariant' +export const name = 'subprocess-local-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/process/process-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts similarity index 92% rename from packages/process/process-local/src/spawn.ts rename to packages/subprocess/subprocess-local/src/spawn.ts index 747b85cf13..95b391ea4b 100644 --- a/packages/process/process-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -1,9 +1,9 @@ /** - * Process plumbing for the local process manager: detached process-group + * Process plumbing for the local subprocess service: detached process-group * spawn, tail-keep output with spill files, and SIGTERM→SIGKILL escalation. * This layer reacts to an abort signal; callers own deadlines and classify * causes. - * @module dsh-process-local/spawn + * @module dsh-subprocess-local/spawn */ import { type ChildProcessByStdio, spawn } from 'node:child_process' @@ -12,8 +12,8 @@ import { randomBytes } from 'node:crypto' import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-process' -import type { CollectedOutput, DshEnvironment, ProcessHandle, ProcessOutcome, ProcessSpawnSpec } from '@deepseek-ai/dsh-process' +import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' +import type { CollectedOutput, DshEnvironment, SubprocessHandle, SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** * Credential-shaped env vars are NOT forwarded to children (the harness's @@ -68,7 +68,7 @@ let defaultSpillDir: string | undefined * other local users read command output or pre-create symlinks. */ function privateSpillDir(): string { - defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-proc-')) + defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-')) return defaultSpillDir } @@ -140,7 +140,7 @@ export class OutputCollector { // prediction and symlink planting in shared tmp dirs. this.spillFile = join( this.spillDir, - `dsh-proc-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`, + `dsh-subprocess-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`, ) this.spillFd = openSync(this.spillFile, 'wx', 0o600) for (const prior of this.chunks) writeSync(this.spillFd, prior) @@ -236,12 +236,12 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void { /** * Spawn one isolated detached process group and collect its output. - * Runtime exits resolve as {@link ProcessOutcome}; only spawn failures reject. + * Runtime exits resolve as {@link SubprocessOutcome}; only spawn failures reject. * @param spec - fully resolved argv, cwd, limits, and cancellation. * @param internals - test-only spill-directory override. * @returns live process handle and outcome promise. */ -export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals = {}): ProcessHandle { +export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle { const spillDir = internals.spillDir ?? privateSpillDir() if (spec.signal?.aborted) { @@ -264,12 +264,17 @@ export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals = child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) }) let graceTimer: NodeJS.Timeout | undefined + let settled = false // Failed spawns use pid -1 so kill remains a no-op. const pid = child.pid ?? -1 const kill = (): void => { if (graceTimer !== undefined) return // escalation already in flight + // After settlement the group is gone and the pid may be reused; callers + // commonly kill() in a finally, so this must not re-signal or start a + // timer that outlives the handle. + if (settled) return killGroup(pid, 'SIGTERM') graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs) } @@ -284,8 +289,7 @@ export function spawnProcess(spec: ProcessSpawnSpec, internals: SpawnInternals = child.stdin.end(spec.stdin) } - const done = new Promise((resolve, reject) => { - let settled = false + const done = new Promise((resolve, reject) => { let pipeDrainTimer: NodeJS.Timeout | undefined const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { if (settled) return diff --git a/packages/process/process-local/tests/manager.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts similarity index 57% rename from packages/process/process-local/tests/manager.spec.ts rename to packages/subprocess/subprocess-local/tests/local.spec.ts index 719dcc1d44..d6f81042b5 100644 --- a/packages/process/process-local/tests/manager.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' -import type { ProcessSpawnSpec } from '@deepseek-ai/dsh-process' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -function spec(command: string, overrides: Partial = {}): ProcessSpawnSpec { +function spec(command: string, overrides: Partial = {}): SubprocessSpawnSpec { return { argv: ['bash', '-c', command], cwd: process.cwd(), @@ -15,11 +15,11 @@ function spec(command: string, overrides: Partial = {}): Proce } } -describe('LocalProcessManager', () => { - it('registers as ctx.processes and spawns managed handles', async () => { +describe('LocalSubprocessService', () => { + it('registers as ctx.subprocess and spawns managed handles', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalProcessManager) - const result = await ctx.processes.spawn(spec('echo managed')).done + const fiber = await ctx.plugin(LocalSubprocessService) + const result = await ctx.subprocess.spawn(spec('echo managed')).done expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('managed\n') await fiber.dispose() @@ -27,8 +27,8 @@ describe('LocalProcessManager', () => { it('disposal kills still-running processes and awaits their exit', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalProcessManager) - const handle = ctx.processes.spawn(spec('sleep 60')) + const fiber = await ctx.plugin(LocalSubprocessService) + const handle = ctx.subprocess.spawn(spec('sleep 60')) await fiber.dispose() const outcome = await handle.done expect(outcome.signal).toBe('SIGTERM') @@ -36,8 +36,8 @@ describe('LocalProcessManager', () => { it('a settled process leaves the live set (disposal does not re-kill it)', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalProcessManager) - const handle = ctx.processes.spawn(spec('true')) + const fiber = await ctx.plugin(LocalSubprocessService) + const handle = ctx.subprocess.spawn(spec('true')) const outcome = await handle.done expect(outcome.exitCode).toBe(0) await fiber.dispose() @@ -45,26 +45,26 @@ describe('LocalProcessManager', () => { it('disposal tolerates a handle whose spawn already failed', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalProcessManager) - const handle = ctx.processes.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-manager-test' })) + const fiber = await ctx.plugin(LocalSubprocessService) + const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' })) await expect(handle.done).rejects.toThrow() await fiber.dispose() }) it('disposal contains a spawn-failure rejection that races teardown', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalProcessManager) + const fiber = await ctx.plugin(LocalSubprocessService) // Dispose before the rejection continuation removes the handle from the // live set, so teardown itself must swallow the rejected done. - const handle = ctx.processes.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-manager-test' })) + const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' })) await fiber.dispose() await expect(handle.done).rejects.toThrow() }) it('loading a second implementation throws (one processes service per context — cordis standard)', async () => { const ctx = new Context() - await ctx.plugin(LocalProcessManager) - class SecondManager extends LocalProcessManager {} - await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "processes" has been registered/) + await ctx.plugin(LocalSubprocessService) + class SecondManager extends LocalSubprocessService {} + await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/) }) }) diff --git a/packages/process/process-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts similarity index 95% rename from packages/process/process-local/tests/spawn.spec.ts rename to packages/subprocess/subprocess-local/tests/spawn.spec.ts index 954196e104..1bc2b27498 100644 --- a/packages/process/process-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -2,9 +2,9 @@ import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import type { DshEnvironment } from '@deepseek-ai/dsh-process' +import type { DshEnvironment } from '@deepseek-ai/dsh-subprocess' import { killGroup, OutputCollector, spawnProcess } from '../src/spawn.ts' -import type { ProcessHandle } from '@deepseek-ai/dsh-process' +import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ failNextClose: { value: false }, @@ -31,7 +31,7 @@ vi.mock('node:fs', async (importOriginal) => { } }) -const spillDir = mkdtempSync(join(tmpdir(), 'dsh-proc-spec-')) +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-')) function spec(command: string, overrides: Partial[0]> = {}) { return { @@ -59,7 +59,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise { throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`) } -async function waitForStdout(running: ProcessHandle, expected: string, timeoutMs = 5_000): Promise { +async function waitForStdout(running: SubprocessHandle, expected: string, timeoutMs = 5_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { if (running.stdout.readFrom(0).text.includes(expected)) return @@ -407,6 +407,21 @@ describe('killGroup', () => { await running.done expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() }) + + it('handle.kill() after settlement signals nothing and starts no grace timer', async () => { + // Cleanup code commonly kills handles in a finally; after settlement the + // group is gone and the pid may be reused, so a late kill must be inert + // (no signal to a possibly-recycled pgid, no referenced timer delaying exit). + const running = spawnProcess(spec('true')) + await running.done + const spy = vi.spyOn(process, 'kill') + try { + running.kill() + expect(spy).not.toHaveBeenCalled() + } finally { + spy.mockRestore() + } + }) }) describe('argv validation', () => { @@ -490,7 +505,7 @@ describe('environment and spill-file hardening', () => { { spillDir }, ).done const path = result.stdout.spillPath! - expect(path).toMatch(/dsh-proc-\d+-\d+-[0-9a-f]{12}-stdout\.log$/) + expect(path).toMatch(/dsh-subprocess-\d+-\d+-[0-9a-f]{12}-stdout\.log$/) const mode = statSync(path).mode & 0o777 expect(mode).toBe(0o600) }) @@ -500,7 +515,7 @@ describe('environment and spill-file hardening', () => { spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), ).done const dir = dirname(result.stdout.spillPath!) - expect(dir).toMatch(/dsh-proc-/) + expect(dir).toMatch(/dsh-subprocess-/) const mode = statSync(dir).mode & 0o777 expect(mode).toBe(0o700) }) diff --git a/packages/process/process-local/tsconfig.json b/packages/subprocess/subprocess-local/tsconfig.json similarity index 92% rename from packages/process/process-local/tsconfig.json rename to packages/subprocess/subprocess-local/tsconfig.json index 5f84f780af..5a8dea211b 100644 --- a/packages/process/process-local/tsconfig.json +++ b/packages/subprocess/subprocess-local/tsconfig.json @@ -15,7 +15,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../process" + "path": "../subprocess" }, { "path": "../../support/invariants" diff --git a/packages/process/process/README.md b/packages/subprocess/subprocess/README.md similarity index 61% rename from packages/process/process/README.md rename to packages/subprocess/subprocess/README.md index b53628cab3..15165a730d 100644 --- a/packages/process/process/README.md +++ b/packages/subprocess/subprocess/README.md @@ -1,16 +1,16 @@ -# @deepseek-ai/dsh-process +# @deepseek-ai/dsh-subprocess -The child-process manager seam (`ctx.processes`). The abstract `ProcessManager` exposes one method — `spawn(spec): ProcessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `ProcessSpawnSpec`, `ProcessHandle` with its non-consuming offset-based output readers, `ProcessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). The local implementation lives in [`dsh-process-local`](../process-local/README.md). +The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes one method — `spawn(spec): SubprocessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with its non-consuming offset-based output readers, `SubprocessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). The local implementation lives in [`dsh-subprocess-local`](../subprocess-local/README.md). ## Contract - `spawn(spec)` returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures. -- The spec is fully explicit — argv, cwd, per-stream byte caps, spill cap, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden process-manager default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted here; a consumer that wants a shell passes `['bash', '-c', command]` itself. +- The spec is fully explicit — argv, cwd, per-stream byte caps, spill cap, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted here; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Output readers take whole-stream byte offsets and never consume: independent readers cannot steal one another's deltas. A read whose offset slid out of the in-memory tail is `lossy` and points at the full-stream spill file when one exists. -- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the manager reacts to the abort but never classifies why (callers own deadlines and cause classification). +- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the service reacts to the abort but never classifies why (callers own deadlines and cause classification). - Disposal kills all still-running managed processes and awaits their exit. -See the [process data-structure catalog](../../../docs/core-data-structures/process.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-process-manager-seam.md). +See the [process data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). ## Model Experience diff --git a/packages/process/process/package.json b/packages/subprocess/subprocess/package.json similarity index 77% rename from packages/process/process/package.json rename to packages/subprocess/subprocess/package.json index 6210948d56..6771c19651 100644 --- a/packages/process/process/package.json +++ b/packages/subprocess/subprocess/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-process", - "description": "Child-process manager seam (ctx.processes) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", + "name": "@deepseek-ai/dsh-subprocess", + "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/process/process/src/index.ts b/packages/subprocess/subprocess/src/index.ts similarity index 65% rename from packages/process/process/src/index.ts rename to packages/subprocess/subprocess/src/index.ts index 4fe3386503..890cf74e61 100644 --- a/packages/process/process/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -1,37 +1,37 @@ /** - * The child-process manager seam (`ctx.processes`): spawn fully-specified + * The subprocess seam (`ctx.subprocess`): spawn fully-specified * commands into managed process groups with bounded, spill-backed output and * escalated kills. Command defaulting, shell semantics, deadlines, and * presentation belong to consumers — the bash executor seam is the owning * template. The local implementation lives in - * `@deepseek-ai/dsh-process-local`. - * @module @deepseek-ai/dsh-process + * `@deepseek-ai/dsh-subprocess-local`. + * @module @deepseek-ai/dsh-subprocess */ import { Context, Service } from 'cordis' -import type { ProcessHandle, ProcessSpawnSpec } from './types.ts' +import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' export { DSH_ENV_PREFIX } from './types.ts' export type { CollectedOutput, DshEnvironment, DshEnvironmentKey, - ProcessHandle, - ProcessOutcome, - ProcessOutputRead, - ProcessOutputReader, - ProcessSpawnSpec, + SubprocessHandle, + SubprocessOutcome, + SubprocessOutputRead, + SubprocessOutputReader, + SubprocessSpawnSpec, } from './types.ts' declare module 'cordis' { interface Context { - processes: ProcessManager + subprocess: SubprocessService } } /** - * Abstract process manager. Subclass, implement {@link spawn}, and load the - * subclass as a plugin — it registers as `ctx.processes` (one implementation + * Abstract subprocess service. Subclass, implement {@link spawn}, and load the + * subclass as a plugin — it registers as `ctx.subprocess` (one implementation * per context; loading a second throws, which is cordis' standard * duplicate-service behavior). * @@ -41,13 +41,13 @@ declare module 'cordis' { * - Output readers are offset-based and non-consuming, so independent readers * never consume one another's output; lossy reads report truncation and the * spill file holding the complete stream when one exists. - * - {@link ProcessHandle.kill} and the spec's abort signal escalate + * - {@link SubprocessHandle.kill} and the spec's abort signal escalate * SIGTERM→grace→SIGKILL across the whole process group. * - Disposal kills all still-running managed processes and awaits their exit. */ -export abstract class ProcessManager extends Service { +export abstract class SubprocessService extends Service { constructor(ctx: Context) { - super(ctx, 'processes') + super(ctx, 'subprocess') } /** @@ -56,7 +56,7 @@ export abstract class ProcessManager extends Service { * @param spec - argv, directory, limits, grace, cancellation, and environment. * @returns the live process handle (readers, kill, outcome promise). */ - abstract spawn(spec: ProcessSpawnSpec): ProcessHandle + abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle } -export default ProcessManager +export default SubprocessService diff --git a/packages/process/process/src/invariant.ts b/packages/subprocess/subprocess/src/invariant.ts similarity index 73% rename from packages/process/process/src/invariant.ts rename to packages/subprocess/subprocess/src/invariant.ts index f54476747e..62a720babd 100644 --- a/packages/process/process/src/invariant.ts +++ b/packages/subprocess/subprocess/src/invariant.ts @@ -1,12 +1,12 @@ -/** Package-owned invariant companion for the process-manager seam. @module @deepseek-ai/dsh-process/invariant */ +/** Package-owned invariant companion for the subprocess seam. @module @deepseek-ai/dsh-subprocess/invariant */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-process' +const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess' /** Cordis companion plugin name. */ -export const name = 'process-invariant' +export const name = 'subprocess-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] @@ -14,7 +14,7 @@ export const inject = ['invariants'] const install: InvariantInstaller = () => {} /** - * Register the process-manager invariant companion. + * Register the subprocess invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/process/process/src/types.ts b/packages/subprocess/subprocess/src/types.ts similarity index 87% rename from packages/process/process/src/types.ts rename to packages/subprocess/subprocess/src/types.ts index 1b81ab8c07..a5ec7f4e55 100644 --- a/packages/process/process/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -1,9 +1,9 @@ /** - * Vocabulary for the process-manager seam: fully-specified spawn requests, + * Vocabulary for the subprocess seam: fully-specified spawn requests, * bounded output with spill recovery, and live process handles. Command * defaulting, shell semantics, and presentation belong to consumers such as * the bash executor seam. - * @module dsh-process/types + * @module dsh-subprocess/types */ /** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ @@ -28,10 +28,10 @@ export interface CollectedOutput { /** * 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 + * subprocess-service default — decides them (the `dsh-bash` request/spec split * is the owning template). */ -export interface ProcessSpawnSpec { +export interface SubprocessSpawnSpec { /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ argv: readonly string[] /** Working directory for the child. */ @@ -70,10 +70,10 @@ export interface ProcessSpawnSpec { /** * Raw outcome of one closed process. Deliberately carries NO timeout or - * cancellation classification: the manager kills on abort but does not decide + * cancellation classification: the service kills on abort but does not decide * why — the caller reads the signal it owns to classify causes. */ -export interface ProcessOutcome { +export interface SubprocessOutcome { /** Exit code; null when the process died from a signal. */ exitCode: number | null /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ @@ -82,8 +82,8 @@ export interface ProcessOutcome { stderr: CollectedOutput } -/** One incremental {@link ProcessOutputReader.readFrom} read. */ -export interface ProcessOutputRead { +/** One incremental {@link SubprocessOutputReader.readFrom} read. */ +export interface SubprocessOutputRead { /** Stream text from the requested offset (the whole retained tail when lossy). */ text: string /** Whole-stream offset to resume from on the next read. */ @@ -99,7 +99,7 @@ export interface ProcessOutputRead { * whole-stream byte coordinates owned by the caller, so independent readers * cannot consume one another's output. */ -export interface ProcessOutputReader { +export interface SubprocessOutputReader { /** * Read everything captured since `fromByte`. When that offset has slid out * of the in-memory tail window the read is `lossy` — it returns the whole @@ -107,22 +107,22 @@ export interface ProcessOutputReader { * @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 + readFrom(fromByte: number): SubprocessOutputRead } /** * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL * escalation; buffered output remains readable after exit. */ -export interface ProcessHandle { +export interface SubprocessHandle { /** Process id (group leader); -1 when the spawn itself failed. */ readonly pid: number /** Live stdout reader (also readable after exit). */ - readonly stdout: ProcessOutputReader + readonly stdout: SubprocessOutputReader /** Live stderr reader (also readable after exit). */ - readonly stderr: ProcessOutputReader + readonly stderr: SubprocessOutputReader /** Resolves when the process closes; rejects only for spawn-level failures. */ - readonly done: Promise + readonly done: Promise /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ kill(): void } diff --git a/packages/process/process/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts similarity index 60% rename from packages/process/process/tests/service.spec.ts rename to packages/subprocess/subprocess/tests/service.spec.ts index 04ffa8c9f4..630aa35f1a 100644 --- a/packages/process/process/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { ProcessManager } from '@deepseek-ai/dsh-process' -import type { ProcessHandle, ProcessOutputRead, ProcessSpawnSpec } from '@deepseek-ai/dsh-process' +import { SubprocessService } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** - * Minimal concrete manager: a hand-built handle. The seam is spawn-only — + * Minimal concrete service: a hand-built handle. The seam is spawn-only — * defaulting, shell semantics, and deadlines belong to callers — so this stub * is all an implementation owes the abstract class. */ -class StubProcessManager extends ProcessManager { - spawn(spec: ProcessSpawnSpec): ProcessHandle { - const read: ProcessOutputRead = { text: '', nextOffset: 0, lossy: false } +class StubSubprocessService extends SubprocessService { + spawn(spec: SubprocessSpawnSpec): SubprocessHandle { + const read: SubprocessOutputRead = { text: '', nextOffset: 0, lossy: false } let killed = false return { pid: spec.argv.length, @@ -27,11 +27,11 @@ class StubProcessManager extends ProcessManager { } } -describe('ProcessManager seam', () => { - it('a concrete subclass registers as ctx.processes and serves the abstract API', async () => { +describe('SubprocessService seam', () => { + it('a concrete subclass registers as ctx.subprocess and serves the abstract API', async () => { const ctx = new Context() - await ctx.plugin(StubProcessManager) - const handle = ctx.processes.spawn({ + await ctx.plugin(StubSubprocessService) + const handle = ctx.subprocess.spawn({ argv: ['true'], cwd: '/stub', stdoutMaxBytes: 1, @@ -48,8 +48,8 @@ describe('ProcessManager seam', () => { it('loading a second implementation throws (one processes service per context — cordis standard)', async () => { const ctx = new Context() - await ctx.plugin(StubProcessManager) - class SecondManager extends StubProcessManager {} - await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "processes" has been registered/) + await ctx.plugin(StubSubprocessService) + class SecondManager extends StubSubprocessService {} + await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/) }) }) diff --git a/packages/process/process/tsconfig.json b/packages/subprocess/subprocess/tsconfig.json similarity index 100% rename from packages/process/process/tsconfig.json rename to packages/subprocess/subprocess/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 340d47c7fd..82f3691044 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,9 +182,6 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../packages/process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session @@ -227,6 +224,9 @@ importers: '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:^ version: link:../../packages/subagent/subagent-spawn + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../packages/subprocess/subprocess-local '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../packages/core/system-prompt @@ -427,9 +427,6 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:* version: link:../packages/plan/plan-mode - '@deepseek-ai/dsh-process-local': - specifier: workspace:* - version: link:../packages/process/process-local '@deepseek-ai/dsh-pty': specifier: workspace:* version: link:../packages/pty/pty @@ -478,6 +475,9 @@ importers: '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:* version: link:../packages/subagent/subagent-spawn + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:* + version: link:../packages/subprocess/subprocess-local '@deepseek-ai/dsh-tasks-local': specifier: workspace:* version: link:../packages/tasks/tasks-local @@ -591,12 +591,12 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@deepseek-ai/dsh-process': - specifier: workspace:^ - version: link:../../process/process '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -613,12 +613,12 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@deepseek-ai/dsh-process': + '@deepseek-ai/dsh-subprocess': specifier: workspace:^ - version: link:../../process/process - '@deepseek-ai/dsh-process-local': + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ - version: link:../../process/process-local + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout @@ -637,9 +637,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../process/process-local '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -649,6 +646,9 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -686,9 +686,6 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../process/process-local '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -704,6 +701,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -1658,9 +1658,6 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../process/process-local '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../sandbox/sandbox-local @@ -1682,6 +1679,9 @@ importers: '@deepseek-ai/dsh-skill-local': specifier: workspace:^ version: link:../../skill/skill-local + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -2004,9 +2004,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../process/process-local '@deepseek-ai/dsh-retention': specifier: workspace:^ version: link:../../util/retention @@ -2016,6 +2013,9 @@ importers: '@deepseek-ai/dsh-spill': specifier: workspace:^ version: link:../../spill/spill + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -2227,9 +2227,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2242,6 +2239,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -2279,9 +2279,6 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2291,6 +2288,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -2646,27 +2646,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/process/process: - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - - packages/process/process-local: - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-process': - specifier: workspace:^ - version: link:../process - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/pty/pty: devDependencies: '@deepseek-ai/dsh-agent': @@ -3623,9 +3602,6 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../process/process-local '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -3635,6 +3611,9 @@ importers: '@deepseek-ai/dsh-subagent-inprocess': specifier: workspace:^ version: link:../subagent-inprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../bash/tool-bash @@ -3694,6 +3673,27 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/subprocess/subprocess: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/subprocess/subprocess-local: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../subprocess + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/support/acp-snapshot: dependencies: '@agentclientprotocol/sdk': @@ -4639,12 +4639,6 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode - '@deepseek-ai/dsh-process': - specifier: workspace:^ - version: link:../../packages/process/process - '@deepseek-ai/dsh-process-local': - specifier: workspace:^ - version: link:../../packages/process/process-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard @@ -4711,6 +4705,12 @@ importers: '@deepseek-ai/dsh-subagent-subprocess': specifier: workspace:^ version: link:../../packages/subagent/subagent-subprocess + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../packages/subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../packages/subprocess/subprocess-local '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../packages/core/system-prompt diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 6fca02ccf8..c2124da532 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -40,8 +40,8 @@ "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-process": "workspace:^", - "@deepseek-ai/dsh-process-local": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml index b59b94a257..2f35e58d43 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -34,8 +34,8 @@ # Local bash executor; $DSH_CWD wins over the process cwd. # Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py index 755e65e00a..07f9b170ce 100644 --- a/python/sdk/tests/test_bundled_runtime.py +++ b/python/sdk/tests/test_bundled_runtime.py @@ -30,8 +30,8 @@ _CORDIS_YML = """\ root: './sessions' - id: session-checkpoints name: '@deepseek-ai/dsh-session-checkpoint-policy' -- id: processes - name: '@deepseek-ai/dsh-process-local' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 07adb0ba0c..82e7b3f6d6 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -64,12 +64,12 @@ export const LINK_MAP: Record = { BashExecSpec: 'bash.md', BashProcess: 'bash.md', BashRunResult: 'bash.md', - DshEnvironment: 'process.md', - ProcessHandle: 'process.md', - ProcessOutcome: 'process.md', - ProcessOutputRead: 'process.md', - ProcessOutputReader: 'process.md', - ProcessSpawnSpec: 'process.md', + DshEnvironment: 'subprocess.md', + SubprocessHandle: 'subprocess.md', + SubprocessOutcome: 'subprocess.md', + SubprocessOutputRead: 'subprocess.md', + SubprocessOutputReader: 'subprocess.md', + SubprocessSpawnSpec: 'subprocess.md', CodeRunRequest: 'code-runtime.md', CodeRunResult: 'code-runtime.md', CompactionResult: 'compaction.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 05cec1dbad..6924cc2761 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -266,13 +266,13 @@ const SERVICE_ROLES: ServiceRole[] = [ note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.', }, { - key: 'processes', + key: 'subprocess', pkg: 'process', title: 'Process manager seam', mode: 'seam', implementations: ['process-local'], consumers: ['bash-local', 'bash-sandbox'], - note: 'The bash executors spawn their process groups through ctx.processes; the manager owns group lifetime, bounded spill-backed output, and kill escalation.', + note: 'The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation.', }, { key: 'bash', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 5d787814eb..fb8ccc17c5 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -19,7 +19,7 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' -import LocalProcessManager from '@deepseek-ai/dsh-process-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import PlanModeService from '@deepseek-ai/dsh-plan-mode' @@ -198,7 +198,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'], writes: ['tool/call', 'tool/result'], async mount(ctx) { - await ctx.plugin(LocalProcessManager) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor) await ctx.plugin(ToolBash) }, diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 629cfe4038..8dfde7ad11 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2163,29 +2163,44 @@ "source": "packages/workflow/workflow/src/types.ts" }, { - "doc": "docs/core-data-structures/process.md", - "symbol": "ProcessSpawnSpec", - "source": "packages/process/process/src/types.ts" + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessSpawnSpec", + "source": "packages/subprocess/subprocess/src/types.ts" }, { - "doc": "docs/core-data-structures/process.md", - "symbol": "ProcessHandle", - "source": "packages/process/process/src/types.ts" + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessHandle", + "source": "packages/subprocess/subprocess/src/types.ts" }, { - "doc": "docs/core-data-structures/process.md", - "symbol": "ProcessOutputReader", - "source": "packages/process/process/src/types.ts" + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessOutputReader", + "source": "packages/subprocess/subprocess/src/types.ts" }, { - "doc": "docs/core-data-structures/process.md", - "symbol": "ProcessOutputRead", - "source": "packages/process/process/src/types.ts" + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessOutputRead", + "source": "packages/subprocess/subprocess/src/types.ts" }, { - "doc": "docs/core-data-structures/process.md", - "symbol": "ProcessOutcome", - "source": "packages/process/process/src/types.ts" + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessOutcome", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "DshEnvironmentKey", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "DshEnvironment", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "CollectedOutput", + "source": "packages/subprocess/subprocess/src/types.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index dc284e9b21..0db4417fa5 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -72,8 +72,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, 'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' }, 'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' }, - 'packages/process/process': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' }, - 'packages/process/process-local': { kind: 'indirect', reason: 'The manager backend delegates model rendering to consumer seams such as the bash executor family.' }, + 'packages/subprocess/subprocess': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' }, + 'packages/subprocess/subprocess-local': { kind: 'indirect', reason: 'The spawn backend delegates model rendering to consumer seams such as the bash executor family.' }, 'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' }, 'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' }, 'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 2f73278c55..79c5ce1a83 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -54,7 +54,7 @@ "./packages/prompt/*/src/invariant.ts", "./packages/llm/*/src/invariant.ts", "./packages/bash/*/src/invariant.ts", - "./packages/process/*/src/invariant.ts", + "./packages/subprocess/*/src/invariant.ts", "./packages/code-runtime/*/src/invariant.ts", "./packages/fs/*/src/invariant.ts", "./packages/skill/*/src/invariant.ts", @@ -124,7 +124,7 @@ "./packages/llm/*/src", "./packages/bash/*/src", "./packages/pty/*/src", - "./packages/process/*/src", + "./packages/subprocess/*/src", "./packages/code-runtime/*/src", "./packages/fs/*/src", "./packages/lsp/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index c4bdc74fe1..a6397660a4 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -81,8 +81,8 @@ { "path": "./packages/llm/llm-retry" }, { "path": "./packages/examples/agent-spine-demo" }, { "path": "./packages/examples/cli-demo" }, - { "path": "./packages/process/process" }, - { "path": "./packages/process/process-local" }, + { "path": "./packages/subprocess/subprocess" }, + { "path": "./packages/subprocess/subprocess-local" }, { "path": "./packages/bash/bash" }, { "path": "./packages/pty/pty" }, { "path": "./packages/pty/pty-local" }, diff --git a/vitest.config.ts b/vitest.config.ts index a0379a325b..48f872bcb9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,7 @@ const windowsUnsupportedPackages = process.platform === 'win32' ? [ 'packages/bash/*', 'packages/hooks/*', - 'packages/process/*', + 'packages/subprocess/*', 'packages/pty/pty-local', 'packages/sandbox/sandbox-local', 'packages/sdk/create-sdk', @@ -41,7 +41,7 @@ const testIncludes = [ // that worker threads cannot isolate reliably under aggregate gate contention. // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ - 'packages/process/process-local/tests/spawn.spec.ts', + 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', 'packages/ui/app-boot/tests/app-boot.spec.ts', From 5e6edce4ed6a2a4ea98e1098409da316f057b2ab Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:57:14 +0800 Subject: [PATCH 15/70] fix(subprocess): finish the rename in generated api-catalog and built-bin closures CI caught two rename escapes: the regenerated api-catalog.ts had not been committed after the LINK_MAP retarget, and the built-bin e2e consumer closures still linked packages/process/* directory paths. --- .../cordis/tool-cordis/src/api-catalog.ts | 32 +++++++++++++++++++ .../examples/acp-demo/tests/built-bin.e2e.ts | 2 +- .../examples/cli-demo/tests/built-bin.e2e.ts | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 59b97aedd5..ec0d279bab 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1458,6 +1458,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CodeRunResult', declaration: 'export interface CodeRunResult {\n value?: CodeJsonValue;\n logs: string[];\n error?: CodeRunFailure;\n}', }, + { + name: 'CollectedOutput', + declaration: 'export interface CollectedOutput {\n text: string;\n truncated: boolean;\n spillPath?: string;\n}', + }, { name: 'CommandDefinition', declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', @@ -1554,6 +1558,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DomainTableSpec', declaration: 'export interface DomainTableSpec {\n readonly valueSchema: ZodType;\n readonly __key?: K;\n}', }, + { + name: 'DshEnvironment', + declaration: 'export type DshEnvironment = Readonly>;', + }, + { + name: 'DshEnvironmentKey', + declaration: 'export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}`;', + }, { name: 'EditGoalRequest', declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}', @@ -2198,6 +2210,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentStopReasonMap', declaration: 'export interface SubagentStopReasonMap {\n completed: \'completed\';\n aborted: \'aborted\';\n error: \'error\';\n \'max-tokens\': \'max-tokens\';\n refusal: \'refusal\';\n}', }, + { + name: 'SubprocessHandle', + declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdout: SubprocessOutputReader;\n readonly stderr: SubprocessOutputReader;\n readonly done: Promise;\n kill(): void;\n}', + }, + { + name: 'SubprocessOutcome', + declaration: 'export interface SubprocessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}', + }, + { + name: 'SubprocessOutputRead', + declaration: 'export interface SubprocessOutputRead {\n text: string;\n nextOffset: number;\n lossy: boolean;\n spillPath?: string;\n}', + }, + { + name: 'SubprocessOutputReader', + declaration: 'export interface SubprocessOutputReader {\n readFrom(fromByte: number): SubprocessOutputRead;\n}', + }, + { + name: 'SubprocessSpawnSpec', + declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdoutMaxBytes: number;\n stderrMaxBytes: number;\n maxSpillBytes: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n}', + }, { name: 'SurfaceEvent', declaration: 'export type SurfaceEvent = SessionEvent & {\n surfaceOp: SurfaceOp;\n};', diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index a82896ab73..c8cf5e3717 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -32,7 +32,7 @@ const decompress = promisify(zstdDecompress) const dshPackages = [ 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', - 'bash/bash-local', 'bash/tool-bash', 'process/process', 'process/process-local', 'context/workspace-context', 'support/invariants', 'ui/app-boot', + 'bash/bash-local', 'bash/tool-bash', 'subprocess/subprocess', 'subprocess/subprocess-local', 'context/workspace-context', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl', 'acp/acp', 'examples/acp-demo', 'util/paths', diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 6f80c983f3..4dca28936a 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -23,7 +23,7 @@ const decompress = promisify(zstdDecompress) const dshPackages = [ 'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', - 'bash/bash-local', 'bash/tool-bash', 'process/process', 'process/process-local', 'support/invariants', 'ui/app-boot', + 'bash/bash-local', 'bash/tool-bash', 'subprocess/subprocess', 'subprocess/subprocess-local', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl', 'context/workspace-context', From 12a7e384174d34195a30bb8083bba2d14f117e51 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:07:42 +0800 Subject: [PATCH 16/70] feat(subprocess): reshape the seam Node-ward for multi-consumer use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review direction (tianyicui, PR #660): make the interface closer to Node's API so the other process-running places can adopt it. The spec gains per-stream stdio dispositions — 'pipe' (raw Readable/Writable for protocol streams), 'inherit' (diagnostics to the parent), and collect mode ({maxBytes, spill?} — the old bounded tail-keep shape, now with spill optional for diagnostic tails). SubprocessOutcome carries exit facts only; collected output stays readable through handle.collected after settlement (spill fds are sealed at the settle boundary). The handle grows Node-style kill(signal) (single signal, tree-scoped, no-op after settlement), terminate() (the SIGTERM→grace→SIGKILL escalation, also driven by the spec signal), waitForExit() (tree liveness, not just the direct child), and dispose() (the cooperative stdin-EOF→SIGTERM→SIGKILL ladder from subagent-subprocess, graces caller-supplied). Tree semantics are platform-correct: POSIX detached groups with direct-child fallback; Windows taskkill /T with an injectable runner. scrubbedParentEnv/SENSITIVE_ENV_PATTERN move to the seam as the one shared scrub definition. bash-local maps its config onto collect modes and batch stdin and reads results through the collected readers; its kill() maps to terminate() so task_kill keeps escalation semantics. --- packages/bash/bash-local/src/index.ts | 58 ++- .../subprocess/subprocess-local/src/index.ts | 30 +- .../subprocess/subprocess-local/src/spawn.ts | 339 +++++++++++++----- .../subprocess-local/tests/local.spec.ts | 13 +- .../subprocess-local/tests/spawn.spec.ts | 279 ++++++++++---- packages/subprocess/subprocess/src/index.ts | 68 +++- packages/subprocess/subprocess/src/types.ts | 200 ++++++++--- .../subprocess/tests/service.spec.ts | 60 ++-- 8 files changed, 786 insertions(+), 261 deletions(-) diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 463e7a96d4..9d9ed676e2 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -11,8 +11,8 @@ import { Context } from 'cordis' import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' -import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' /** @@ -54,6 +54,16 @@ export interface Config { /** The shape after schemastery applied the defaults (cwd has none). */ type ResolvedConfig = Required> & Pick +/** Project a settled collect-mode reader into the final CollectedOutput shape. */ +function finalOutput(reader: SubprocessOutputReader): CollectedOutput { + const read = reader.readFrom(0) + return { + text: read.text, + truncated: read.lossy, + ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {}, + } +} + function assertPositiveFinite(name: string, value: number): void { if (!Number.isFinite(value) || value <= 0) { throw new Error(`bash-local: ${name} must be a positive finite number`) @@ -127,36 +137,58 @@ export class LocalBashExecutor extends BashExecutor { } } - /** Map one resolved bash spec onto a fully-specified process spawn. */ + /** Map one resolved bash spec onto a fully-specified subprocess spawn. */ // XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { + const collect = (maxBytes: number): SubprocessCollect => + ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) return { argv: ['bash', '-c', spec.command], cwd: spec.workdir, - stdoutMaxBytes, - stderrMaxBytes: this.config.maxOutputBytes, - maxSpillBytes: this.config.maxSpillBytes, + stdio: { + stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', + stdout: collect(stdoutMaxBytes), + stderr: collect(this.config.maxOutputBytes), + }, graceMs: this.config.graceMs, signal, - stdin: spec.stdin, env: { ...ENV_OVERRIDES, ...spec.env }, dshEnv: spec.dshEnv, } } + /** The collect-mode readers the executor itself requested (present by construction). */ + private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } { + const { stdout, stderr } = handle.collected + if (stdout === undefined || stderr === undefined) { + throw new Error('bash-local: subprocess implementation dropped a requested collect stream') + } + return { stdout, stderr } + } + async run(spec: BashExecSpec): Promise { // One deadline combines timeout and upstream cancellation; disposal clears its timer. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') - const outcome = await this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)).done + const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)) + const outcome = await handle.done + const collected = LocalBashExecutor.collected(handle) // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined const aborted = d.signal.aborted && !timedOut - return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs } + return { + ...outcome, + timedOut, + aborted, + timeoutMs: spec.timeoutMs, + stdout: finalOutput(collected.stdout), + stderr: finalOutput(collected.stderr), + } } start(spec: BashExecSpec): BashProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + const collected = LocalBashExecutor.collected(running) // A spawn failure produces no process output, so the subprocess service has nothing // to buffer; the note is delivered exactly once through the read path. @@ -180,7 +212,7 @@ export class LocalBashExecutor extends BashExecutor { } proc.exitCode = outcome.exitCode proc.signal = outcome.signal - this.onProcessDone(proc, running.stderr.readFrom(0).text) + this.onProcessDone(proc, collected.stderr.readFrom(0).text) }, (error: unknown) => { // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' @@ -188,8 +220,8 @@ export class LocalBashExecutor extends BashExecutor { this.onProcessDone(proc, spawnFailureNote) }), readOutput: (): BashProcessRead => { - const out = running.stdout.readFrom(stdoutOffset) - const err = running.stderr.readFrom(stderrOffset) + const out = collected.stdout.readFrom(stdoutOffset) + const err = collected.stderr.readFrom(stderrOffset) stdoutOffset = out.nextOffset stderrOffset = err.nextOffset @@ -211,7 +243,7 @@ export class LocalBashExecutor extends BashExecutor { kill: (): boolean => { if (proc.status !== 'running') return false proc.status = 'killed' - running.kill() + running.terminate() return true }, } diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index a5256f325b..d76e5cf410 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -1,36 +1,38 @@ /** - * Local-subprocess implementation of the subprocess seam. Each spawn is - * a detached process group with bounded, spill-backed output; disposal kills - * and joins live groups. It has no config: every limit arrives on the spec, - * so the deployment-varying choices stay with the calling seam's config (the - * bash executor's, today). + * Local implementation of the subprocess seam. Each spawn is a detached + * process tree with the spec's per-stream stdio dispositions; disposal + * terminates and joins live trees. It has no config: every disposition and + * limit arrives on the spec, so the deployment-varying choices stay with the + * calling seam's config (the bash executor's, the LSP host's, …). * @module @deepseek-ai/dsh-subprocess-local */ import { Context } from 'cordis' import { SubprocessService } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { spawnProcess } from './spawn.ts' +import { spawnSubprocess } from './spawn.ts' import type { SpawnInternals } from './spawn.ts' /** - * Local subprocess service: detached process groups, tail-keep truncation with - * bounded spill files, credential-scrubbed environment, and group - * SIGTERM→grace→SIGKILL escalation. + * Local subprocess service: detached process trees, Node-shaped stdio + * dispositions (raw pipes, inherit, bounded tail-keep collection with spill + * files), credential-scrubbed environment, tree-scoped signalling with + * SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder. */ export class LocalSubprocessService extends SubprocessService { - /** Live handles retained only so disposal can kill and join them. */ + /** Live handles retained only so disposal can terminate and join them. */ private live = new Set() - /** Test seam: spill knobs forwarded to spawnProcess. */ + /** Test seam: spill and platform knobs forwarded to spawnSubprocess. */ internals: SpawnInternals = {} constructor(ctx: Context) { super(ctx) ctx.effect(() => async () => { - // Await closure so even a TERM-trapping child cannot outlive the fiber. + // Terminate (escalating), then await closure so even a TERM-trapping + // child cannot outlive the fiber. const pending: Promise[] = [] for (const handle of this.live) { - handle.kill() + handle.terminate() // Spawn-failure rejections already settled and left the live set. pending.push(handle.done.catch(() => {})) } @@ -40,7 +42,7 @@ export class LocalSubprocessService extends SubprocessService { } spawn(spec: SubprocessSpawnSpec): SubprocessHandle { - const handle = spawnProcess(spec, this.internals) + const handle = spawnSubprocess(spec, this.internals) this.live.add(handle) handle.done.then( () => { this.live.delete(handle) }, diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 95b391ea4b..f939b9e774 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -1,33 +1,36 @@ /** - * Process plumbing for the local subprocess service: detached process-group - * spawn, tail-keep output with spill files, and SIGTERM→SIGKILL escalation. + * Process plumbing for the local subprocess service: detached process-tree + * spawn with per-stream stdio dispositions, tail-keep collection with spill + * files, tree-scoped signalling (POSIX groups; Windows taskkill), the + * SIGTERM→SIGKILL escalation, and the cooperative EOF-first dispose ladder. * This layer reacts to an abort signal; callers own deadlines and classify * causes. * @module dsh-subprocess-local/spawn */ -import { type ChildProcessByStdio, spawn } from 'node:child_process' -import type { Readable, Writable } from 'node:stream' +import { type ChildProcess, spawn, spawnSync } from 'node:child_process' +import type { Readable } from 'node:stream' import { randomBytes } from 'node:crypto' import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' -import type { CollectedOutput, DshEnvironment, SubprocessHandle, SubprocessOutcome, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { DSH_ENV_PREFIX, scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import type { + CollectedOutput, + DshEnvironment, + SubprocessCollect, + SubprocessDisposeGraces, + SubprocessHandle, + SubprocessOutcome, + SubprocessOutputMode, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' /** - * Credential-shaped env vars are NOT forwarded to children (the harness's - * own DEEPSEEK_API_KEY must not leak into `env` output, tool results, or - * spill files). Same default pattern as Codex's env policy; a future config - * can whitelist specific vars when a workflow genuinely needs one. - */ -export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i - -/** - * Build a child environment from scrubbed ambient values, ordinary caller - * entries, and a managed `DSH_*` snapshot. Ambient managed names are removed; - * ordinary and managed entries reject the other channel's namespace before - * `dshEnv` merges last. + * Build a child environment from the scrubbed parent base, ordinary caller + * entries, and a managed `DSH_*` snapshot. Ordinary and managed entries + * reject the other channel's namespace before `dshEnv` merges last. * @param extra - caller entries; `DSH_*` names are rejected. * @param dshEnv - managed entries; non-`DSH_*` names are rejected. * @returns the environment to hand to `spawn` for the child process. @@ -36,10 +39,6 @@ export function childEnv( extra?: Readonly>, dshEnv?: DshEnvironment, ): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {} - for (const [key, value] of Object.entries(process.env)) { - if (!SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value - } for (const key of Object.keys(extra ?? {})) { if (key.startsWith(DSH_ENV_PREFIX)) { throw new Error(`ordinary child env cannot set reserved variable "${key}"; use dshEnv`) @@ -50,13 +49,17 @@ export function childEnv( throw new Error(`managed child env cannot set ordinary variable "${key}"; use env`) } } - return { ...env, ...extra, ...dshEnv } + return { ...scrubbedParentEnv(), ...extra, ...dshEnv } } -/** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */ +/** Injectable knobs so tests can exercise spill and platform behavior deterministically. */ export interface SpawnInternals { /** Directory for spill files (defaults to the OS temp dir). */ spillDir?: string + /** Windows tree-termination runner (defaults to `taskkill /PID /T /F`). */ + taskkill?: (pid: number) => void + /** Host platform override for signalling decisions. */ + platform?: NodeJS.Platform } let spillCounter = 0 @@ -73,9 +76,11 @@ function privateSpillDir(): string { } /** - * Collects one stream with a bounded in-memory tail. On first overflow a - * spill file is created and every chunk (including those already collected) - * is appended there while the full stream remains within `maxSpillBytes`. + * Collects one stream with a bounded in-memory tail. With a spill cap, on + * first overflow a spill file is created and every chunk (including those + * already collected) is appended there while the full stream remains within + * the cap; without one, only the in-memory tail is ever retained (the + * diagnostic-tail shape — a language server's stderr). * * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the * end of command output; the spill file covers the head. @@ -86,23 +91,25 @@ export class OutputCollector { private dropped = false private spillFd: number | undefined private spillFile: string | undefined - private spillDisabled = false + private spillDisabled: boolean /** Total bytes ever pushed (not just retained). */ private total = 0 constructor( private readonly maxBytes: number, - private readonly maxSpillBytes: number, + private readonly maxSpillBytes: number | undefined, private readonly label: string, private readonly spillDir: string, - ) {} + ) { + this.spillDisabled = maxSpillBytes === undefined + } /** * Ingest one stream chunk, counting it toward the whole-stream total. On - * first overflow of the in-memory cap a spill file is opened and every chunk - * (already-collected ones included) is appended there from then on; the - * in-memory tail then drops whole chunks from its head (or the head of a - * single over-cap chunk) until it fits the cap again. + * first overflow of the in-memory cap a spill file is opened (when spilling + * is enabled) and every chunk (already-collected ones included) is appended + * there from then on; the in-memory tail then drops whole chunks from its + * head (or the head of a single over-cap chunk) until it fits the cap again. * @param chunk - the raw bytes from one stream 'data' event. */ push(chunk: Buffer): void { @@ -130,7 +137,7 @@ export class OutputCollector { /** Open the spill file lazily and append `chunk` (and any prior chunks once). */ private spillAll(chunk: Buffer): void { - if (this.total > this.maxSpillBytes) { + if (this.maxSpillBytes !== undefined && this.total > this.maxSpillBytes) { this.discardSpill() return } @@ -194,22 +201,30 @@ export class OutputCollector { } /** - * Close the spill file (if any) and return the final output. A failed close - * (delayed writeback fault) stops advertising the spill path — the file may - * be missing its tail — but still returns the in-memory result. + * Close the spill file once the stream has ended. A failed close (delayed + * writeback fault) stops advertising the spill path — the file may be + * missing its tail — while every in-memory read keeps working. Idempotent; + * the spawn path seals both collectors at settlement so reads after exit + * never point at a still-open file. + */ + seal(): void { + if (this.spillFd === undefined) return + try { + closeSync(this.spillFd) + } catch { + // A delayed writeback failure makes the spill unreliable; keep the + // in-memory result but stop advertising that file. + this.spillFile = undefined + } + this.spillFd = undefined + } + + /** + * Seal the spill file and return the final output. * @returns the final collected output: tail text, truncation flag, and the spill path when intact. */ finalize(): CollectedOutput { - if (this.spillFd !== undefined) { - try { - closeSync(this.spillFd) - } catch { - // A delayed writeback failure makes the spill unreliable; keep finalize - // total but stop advertising that file. - this.spillFile = undefined - } - this.spillFd = undefined - } + this.seal() return { text: Buffer.concat(this.chunks).toString('utf8'), truncated: this.dropped, @@ -219,9 +234,9 @@ export class OutputCollector { } /** - * Send `sig` to a detached process group. Never throws: delivery races process - * exit and may run in a timer callback, so failures are contained and a - * non-positive pid is a no-op. + * Send `sig` to a detached POSIX process group. Never throws: delivery races + * process exit and may run in a timer callback, so failures are contained and + * a non-positive pid is a no-op. * @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op. * @param sig - the signal to deliver to the whole group. */ @@ -235,14 +250,60 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void { } /** - * Spawn one isolated detached process group and collect its output. - * Runtime exits resolve as {@link SubprocessOutcome}; only spawn failures reject. - * @param spec - fully resolved argv, cwd, limits, and cancellation. - * @param internals - test-only spill-directory override. - * @returns live process handle and outcome promise. + * Terminate one Windows process tree with `taskkill /T /F`. Contained like + * POSIX group signalling — delivery races tree exit, so an absent tree, a + * nonzero status, or a missing taskkill binary must not break idempotent + * teardown. + * @param pid - root process id; non-positive is a no-op. */ -export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle { +export function taskkillProcessTree(pid: number): void { + if (pid <= 0) return + // Outcome deliberately unchecked: an already-absent tree (status 128) and + // exit races are as tolerable here as ESRCH is for a POSIX group signal. + spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) +} + +/** + * Signal a detached process tree with platform-correct semantics: POSIX + * signals the negative process-group id and falls back to the direct child + * when the group is gone; Windows terminates the tree via taskkill (any + * signal value force-terminates — Node maps signals to TerminateProcess). + */ +function signalTree( + platform: NodeJS.Platform, + pid: number, + sig: NodeJS.Signals, + child: ChildProcess, + taskkill: (pid: number) => void, +): void { + if (platform === 'win32') { + taskkill(pid) + return + } + if (pid <= 0) return + try { + process.kill(-pid, sig) + } catch { + try { + child.kill(sig) + } catch { + // The direct child already exited; teardown remains idempotent. + } + } +} + +/** + * Spawn one isolated detached process tree with the spec's per-stream stdio + * dispositions. Runtime exits resolve `done` as {@link SubprocessOutcome}; + * only spawn failures reject. + * @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment. + * @param internals - test-only spill-directory, platform, and taskkill overrides. + * @returns live subprocess handle. + */ +export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle { const spillDir = internals.spillDir ?? privateSpillDir() + const platform = internals.platform ?? process.platform + const taskkill = internals.taskkill ?? taskkillProcessTree if (spec.signal?.aborted) { throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) @@ -252,41 +313,66 @@ export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternal throw new Error('invalid argv: expected a non-empty program name at argv[0]') } - // Keep absent stdin as /dev/null; literal tuples preserve non-null output types. - const env = childEnv(spec.env, spec.dshEnv) - const child: ChildProcessByStdio = spec.stdin !== undefined - ? spawn(program, args, { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }) - : spawn(program, args, { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) + const isCollect = (mode: SubprocessOutputMode): mode is SubprocessCollect => + mode !== 'pipe' && mode !== 'inherit' + const outMode = spec.stdio.stdout + const errMode = spec.stdio.stderr + const stdinMode = spec.stdio.stdin - const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir) - const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir) - child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) }) - child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) }) + const env = childEnv(spec.env, spec.dshEnv) + const child = spawn(program, args, { + cwd: spec.cwd, + env, + stdio: [ + stdinMode === 'ignore' ? 'ignore' : 'pipe', + outMode === 'inherit' ? 'inherit' : 'pipe', + errMode === 'inherit' ? 'inherit' : 'pipe', + ], + // `detached` gives teardown a tree root on POSIX (its own process group); + // Windows terminates by root pid through taskkill /T instead. + detached: platform !== 'win32', + }) + + const collectStream = (mode: SubprocessOutputMode, stream: Readable | null, label: string): OutputCollector | undefined => { + if (!isCollect(mode) || stream === null) return undefined + const collector = new OutputCollector(mode.maxBytes, mode.spill?.maxBytes, label, spillDir) + stream.on('data', (chunk: Buffer) => { collector.push(chunk) }) + return collector + } + const stdoutCollector = collectStream(outMode, child.stdout, 'stdout') + const stderrCollector = collectStream(errMode, child.stderr, 'stderr') let graceTimer: NodeJS.Timeout | undefined let settled = false - // Failed spawns use pid -1 so kill remains a no-op. + // Failed spawns use pid -1 so signalling remains a no-op. const pid = child.pid ?? -1 - const kill = (): void => { - if (graceTimer !== undefined) return // escalation already in flight - // After settlement the group is gone and the pid may be reused; callers - // commonly kill() in a finally, so this must not re-signal or start a - // timer that outlives the handle. + const kill = (sig: NodeJS.Signals = 'SIGTERM'): void => { + // After settlement the tree is gone and the pid may be reused; callers + // commonly kill() in a finally, so this must not re-signal. if (settled) return - killGroup(pid, 'SIGTERM') - graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs) + signalTree(platform, pid, sig, child, taskkill) + } + + const terminate = (): void => { + if (graceTimer !== undefined) return // escalation already in flight + if (settled) return + signalTree(platform, pid, 'SIGTERM', child, taskkill) + graceTimer = setTimeout(() => { + if (!settled) signalTree(platform, pid, 'SIGKILL', child, taskkill) + }, spec.graceMs) } // The caller owns timeout classification; this layer only reacts to abort. - const onAbort = (): void => { kill() } + const onAbort = (): void => { terminate() } spec.signal?.addEventListener('abort', onAbort, { once: true }) - // Stdin writes are best-effort; process exit and captured output remain authoritative. - if (child.stdin !== null) { + // Batch stdin is written and closed up front; process exit and captured + // output remain authoritative, so write errors (EPIPE) are best-effort. + if (typeof stdinMode === 'object' && child.stdin !== null) { child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) - child.stdin.end(spec.stdin) + child.stdin.end(stdinMode.data) } const done = new Promise((resolve, reject) => { @@ -294,15 +380,14 @@ export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternal const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { if (settled) return settled = true - child.stdout.destroy() - child.stderr.destroy() + // Only harness-collected pipes are force-closed at the drain boundary; + // a 'pipe'-mode stream belongs to the caller and closes with the child. + if (stdoutCollector !== undefined) child.stdout?.destroy() + if (stderrCollector !== undefined) child.stderr?.destroy() + stdoutCollector?.seal() + stderrCollector?.seal() cleanup() - resolve({ - exitCode, - signal, - stdout: stdout.finalize(), - stderr: stderr.finalize(), - }) + resolve({ exitCode, signal }) } child.on('error', (error) => { // No meaningful close outcome follows a spawn failure. @@ -311,6 +396,9 @@ export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternal reject(error) }) child.on('exit', (exitCode, signal) => { + // A surviving descendant that inherited a pipe must not hold the + // outcome open indefinitely: after exit, the same bounded grace that + // governs kills also bounds the close wait. pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs) }) child.on('close', settle) @@ -321,5 +409,82 @@ export function spawnProcess(spec: SubprocessSpawnSpec, internals: SpawnInternal } }) - return { pid, stdout, stderr, done, kill } + /** Whether the detached tree's root (or POSIX group) is still alive. */ + const treeAlive = (): boolean => { + if (pid <= 0) return false + if (platform === 'win32') { + // Windows has no group-liveness probe; the direct child's exit is the + // observable boundary (taskkill /T already took the tree with it). + return child.exitCode === null && child.signalCode === null + } + try { + process.kill(-pid, 0) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ESRCH') return false + /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs + tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */ + if (code === 'EPERM') return true + return child.exitCode === null && child.signalCode === null + /* v8 ignore stop */ + } + } + + const waitForExit = async (signal?: AbortSignal): Promise => { + while (treeAlive()) { + if (signal?.aborted) return false + await yieldToEventLoop() + } + return true + } + + /** Race settlement against a timer without leaving listeners or live timers behind. */ + const settlesWithin = async (ms: number): Promise => { + if (settled) return true + let timer: NodeJS.Timeout | undefined + const timeout = new Promise((resolve) => { + // `.unref()` so a pending grace timer never keeps the parent's loop alive. + timer = setTimeout(() => { resolve(false) }, ms) + timer.unref() + }) + try { + return await Promise.race([done.then(() => true, () => true), timeout]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } + } + + let disposal: Promise | undefined + const dispose = (graces: SubprocessDisposeGraces): Promise => (disposal ??= (async () => { + // 1. Close a piped stdin and allow cooperative teardown and flush. + if (stdinMode === 'pipe') child.stdin?.end() + if (await settlesWithin(graces.eofGraceMs)) return + // 2. POSIX gets a catchable graceful signal; Windows taskkill force-terminates. + if (platform !== 'win32') { + kill('SIGTERM') + if (await settlesWithin(graces.graceMs)) return + } + // 3. Force-kill the tree and await a bounded exit edge. + kill('SIGKILL') + if (!(await settlesWithin(graces.graceMs))) { + throw new Error(`child process did not exit within ${graces.graceMs}ms after forced termination`) + } + })()) + + return { + pid, + stdin: stdinMode === 'pipe' ? child.stdin ?? undefined : undefined, + stdout: outMode === 'pipe' ? child.stdout ?? undefined : undefined, + stderr: errMode === 'pipe' ? child.stderr ?? undefined : undefined, + collected: { + ...stdoutCollector !== undefined ? { stdout: stdoutCollector } : {}, + ...stderrCollector !== undefined ? { stderr: stderrCollector } : {}, + }, + done, + kill, + terminate, + waitForExit, + dispose, + } } diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index d6f81042b5..ca723fdb87 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -7,9 +7,11 @@ function spec(command: string, overrides: Partial = {}): Su return { argv: ['bash', '-c', command], cwd: process.cwd(), - stdoutMaxBytes: 64_000, - stderrMaxBytes: 64_000, - maxSpillBytes: 64 * 1024 * 1024, + stdio: { + stdin: 'ignore', + stdout: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } }, + stderr: { maxBytes: 64_000, spill: { maxBytes: 64 * 1024 * 1024 } }, + }, graceMs: 200, ...overrides, } @@ -19,9 +21,10 @@ describe('LocalSubprocessService', () => { it('registers as ctx.subprocess and spawns managed handles', async () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessService) - const result = await ctx.subprocess.spawn(spec('echo managed')).done + const handle = ctx.subprocess.spawn(spec('echo managed')) + const result = await handle.done expect(result.exitCode).toBe(0) - expect(result.stdout.text).toBe('managed\n') + expect(handle.collected.stdout!.readFrom(0).text).toBe('managed\n') await fiber.dispose() }) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 1bc2b27498..2f48b0fef3 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import type { DshEnvironment } from '@deepseek-ai/dsh-subprocess' -import { killGroup, OutputCollector, spawnProcess } from '../src/spawn.ts' -import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import { killGroup, OutputCollector, spawnSubprocess } from '../src/spawn.ts' +import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ failNextClose: { value: false }, @@ -33,15 +33,25 @@ vi.mock('node:fs', async (importOriginal) => { const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-')) -function spec(command: string, overrides: Partial[0]> = {}) { +type SpecOverrides = Partial[0]> & { + stdoutMaxBytes?: number + stderrMaxBytes?: number + maxSpillBytes?: number + stdin?: string +} + +function spec(command: string, overrides: SpecOverrides = {}) { + const { stdoutMaxBytes = 64_000, stderrMaxBytes = 64_000, maxSpillBytes = 64 * 1024 * 1024, stdin, ...rest } = overrides return { argv: ['bash', '-c', command], cwd: process.cwd(), - stdoutMaxBytes: 64_000, - stderrMaxBytes: 64_000, - maxSpillBytes: 64 * 1024 * 1024, + stdio: { + stdin: stdin !== undefined ? { data: stdin } : 'ignore' as const, + stdout: { maxBytes: stdoutMaxBytes, spill: { maxBytes: maxSpillBytes } }, + stderr: { maxBytes: stderrMaxBytes, spill: { maxBytes: maxSpillBytes } }, + }, graceMs: 3_000, - ...overrides, + ...rest, } } @@ -62,12 +72,22 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise { async function waitForStdout(running: SubprocessHandle, expected: string, timeoutMs = 5_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { - if (running.stdout.readFrom(0).text.includes(expected)) return + if (running.collected.stdout!.readFrom(0).text.includes(expected)) return await new Promise(resolve => setTimeout(resolve, 20)) } throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`) } +/** Await settlement and project both collected streams like a batch outcome. */ +async function finish(running: SubprocessHandle) { + const outcome = await running.done + const final = (reader: SubprocessOutputReader | undefined) => { + const read = reader!.readFrom(0) + return { text: read.text, truncated: read.lossy, ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {} } + } + return { ...outcome, stdout: final(running.collected.stdout), stderr: final(running.collected.stderr) } +} + async function waitForPidFile(path: string, timeoutMs = 5_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { @@ -82,9 +102,9 @@ async function waitForPidFile(path: string, timeoutMs = 5_000): Promise throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`) } -describe('spawnProcess', () => { +describe('spawnSubprocess', () => { it('captures stdout on success', async () => { - const result = await spawnProcess(spec('echo hello')).done + const result = await finish(spawnSubprocess(spec('echo hello'))) expect(result.exitCode).toBe(0) expect(result.signal).toBeNull() expect(result.stdout.text).toBe('hello\n') @@ -93,33 +113,33 @@ describe('spawnProcess', () => { }) it('captures stderr separately', async () => { - const result = await spawnProcess(spec('echo oops >&2')).done + const result = await finish(spawnSubprocess(spec('echo oops >&2'))) expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('') expect(result.stderr.text).toBe('oops\n') }) it('captures both streams', async () => { - const result = await spawnProcess(spec('echo out; echo err >&2')).done + const result = await finish(spawnSubprocess(spec('echo out; echo err >&2'))) expect(result.stdout.text).toBe('out\n') expect(result.stderr.text).toBe('err\n') }) it('reports non-zero exit codes', async () => { - const result = await spawnProcess(spec('exit 42')).done + const result = await finish(spawnSubprocess(spec('exit 42'))) expect(result.exitCode).toBe(42) expect(result.signal).toBeNull() }) it('passes the ambient TERM through untouched (terminal policy is the caller\'s)', async () => { - const result = await spawnProcess(spec('echo "${TERM:-unset}"', { + const result = await finish(spawnSubprocess(spec('echo "${TERM:-unset}"', { env: { TERM: 'callers-choice' }, - })).done + }))) expect(result.stdout.text).toBe('callers-choice\n') }) it('runs in the requested cwd', async () => { - const result = await spawnProcess(spec('pwd', { cwd: '/tmp' })).done + const result = await finish(spawnSubprocess(spec('pwd', { cwd: '/tmp' }))) expect(result.stdout.text.trim()).toMatch(/\/tmp$/) }) @@ -129,7 +149,7 @@ describe('spawnProcess', () => { // assert the kill itself lands as SIGTERM. const controller = new AbortController() const start = Date.now() - const running = spawnProcess(spec('sleep 60', { signal: controller.signal })) + const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort('deadline') }, 100) const result = await running.done expect(Date.now() - start).toBeLessThan(5_000) @@ -137,10 +157,21 @@ describe('spawnProcess', () => { expect(result.exitCode).toBeNull() }) - it('escalates to SIGKILL when SIGTERM is trapped', async () => { - const running = spawnProcess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) + it('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => { + const running = spawnSubprocess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) await waitForStdout(running, 'ready\n') - running.kill() + running.terminate() + const result = await running.done + expect(result.signal).toBe('SIGKILL') + }) + + it('kill() sends one signal Node-style, without escalation', async () => { + const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60', { graceMs: 100 })) + await waitForStdout(running, 'armed\n') + running.kill() // trapped SIGTERM, no SIGKILL follow-up + await new Promise(resolve => setTimeout(resolve, 400)) + expect(running.collected.stdout).toBeDefined() + running.kill('SIGKILL') // explicit signal choice, still no timers const result = await running.done expect(result.signal).toBe('SIGKILL') }) @@ -149,7 +180,7 @@ describe('spawnProcess', () => { // The subshell writes the sleep's pid then waits on it; killing the // group must take the sleep down with bash. const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`) - const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) + const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) const grandchild = await waitForPidFile(pidFile) expect(grandchild).toBeGreaterThan(0) @@ -161,7 +192,7 @@ describe('spawnProcess', () => { it('aborts via AbortSignal mid-run', async () => { const controller = new AbortController() - const running = spawnProcess(spec('sleep 60', { signal: controller.signal })) + const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort('user cancelled') }, 50) const result = await running.done expect(result.signal).toBe('SIGTERM') @@ -170,19 +201,19 @@ describe('spawnProcess', () => { it('throws when the signal is already aborted before spawn', () => { const controller = new AbortController() controller.abort('too late') - expect(() => spawnProcess(spec('echo hi', { signal: controller.signal }))) + expect(() => spawnSubprocess(spec('echo hi', { signal: controller.signal }))) .toThrow(/aborted before spawn: too late/) }) it('rejects with a spawn error for a nonexistent cwd', async () => { - await expect(spawnProcess(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done) + await expect(spawnSubprocess(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done) .rejects.toThrow(/ENOENT/) }) - it('kill() is idempotent (second call does not restart escalation)', async () => { - const running = spawnProcess(spec('sleep 60')) - running.kill() - running.kill() + it('terminate() is idempotent (second call does not restart escalation)', async () => { + const running = spawnSubprocess(spec('sleep 60')) + running.terminate() + running.terminate() const result = await running.done expect(result.signal).toBe('SIGTERM') }) @@ -190,10 +221,10 @@ describe('spawnProcess', () => { it('bounds inherited-pipe draining after the shell exits', async () => { const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) const started = Date.now() - const running = spawnProcess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) + const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) const descendant = await waitForPidFile(pidFile) try { - const result = await running.done + const result = await finish(running) expect(Date.now() - started).toBeLessThan(1_000) expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('shell-done\n') @@ -206,7 +237,7 @@ describe('spawnProcess', () => { describe('stdin and extra env (set by in-process plugins)', () => { it('writes stdin to the command and closes it', async () => { - const result = await spawnProcess(spec('cat', { stdin: 'hello from stdin\n' })).done + const result = await finish(spawnSubprocess(spec('cat', { stdin: 'hello from stdin\n' }))) expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('hello from stdin\n') }) @@ -214,7 +245,7 @@ describe('stdin and extra env (set by in-process plugins)', () => { it('a command that reads stdin sees EOF when none is supplied', async () => { // No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no // output (it does NOT block). - const result = await spawnProcess(spec('cat')).done + const result = await finish(spawnSubprocess(spec('cat'))) expect(result.exitCode).toBe(0) expect(result.stdout.text).toBe('') }) @@ -222,25 +253,25 @@ describe('stdin and extra env (set by in-process plugins)', () => { it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { // With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device). // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO. - const none = await spawnProcess(spec('test -c /dev/stdin && echo char || echo other')).done + const none = await finish(spawnSubprocess(spec('test -c /dev/stdin && echo char || echo other'))) expect(none.stdout.text).toBe('char\n') - const piped = await spawnProcess(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done + const piped = await finish(spawnSubprocess(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' }))) expect(piped.stdout.text).toBe('socket\n') }) it('merges ordinary extra env entries onto the scrubbed environment', async () => { - const result = await spawnProcess(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', { + const result = await finish(spawnSubprocess(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', { env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' }, - })).done + }))) expect(result.stdout.text).toBe('alpha/beta\n') }) it('an explicit extra env entry overrides the credential scrub', async () => { // EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit // entry is still honored — the scrub only drops AMBIENT process.env creds. - const result = await spawnProcess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', { + const result = await finish(spawnSubprocess(spec('echo "$EXPLICIT_OVERRIDE_KEY"', { env: { EXPLICIT_OVERRIDE_KEY: 'explicit-wins' }, - })).done + }))) expect(result.stdout.text).toBe('explicit-wins\n') }) @@ -248,20 +279,20 @@ describe('stdin and extra env (set by in-process plugins)', () => { // The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE. // The handler swallows that write error and `done` reports the child's real exit. const big = 'x'.repeat(1024 * 1024) - const result = await spawnProcess(spec('exit 7', { stdin: big })).done + const result = await finish(spawnSubprocess(spec('exit 7', { stdin: big }))) expect(result.exitCode).toBe(7) }) }) describe('output truncation and spill', () => { it('applies stdout and stderr caps independently', async () => { - const result = await spawnProcess( + const result = await finish(spawnSubprocess( spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', { stdoutMaxBytes: 500, stderrMaxBytes: 100, }), { spillDir }, - ).done + )) expect(result.stdout.truncated).toBe(false) expect(result.stdout.text).toBe('x'.repeat(500)) expect(result.stderr.truncated).toBe(true) @@ -270,10 +301,10 @@ describe('output truncation and spill', () => { it('keeps the tail and spills the full stream to disk', async () => { // 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail. - const result = await spawnProcess( + const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, - ).done + )) expect(result.stdout.truncated).toBe(true) expect(result.stdout.text.length).toBeLessThanOrEqual(500) expect(result.stdout.text).toContain('line-0200') @@ -285,10 +316,10 @@ describe('output truncation and spill', () => { }) it('does not truncate output exactly at the cap', async () => { - const result = await spawnProcess( + const result = await finish(spawnSubprocess( spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, - ).done + )) expect(result.stdout.truncated).toBe(false) expect(result.stdout.text.length).toBe(500) expect(result.stdout.spillPath).toBeUndefined() @@ -296,10 +327,10 @@ describe('output truncation and spill', () => { it('settles with the tail and no spill path when final spill close fails', async () => { failNextClose.value = true - const result = await spawnProcess( + const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, - ).done + )) expect(failNextClose.value).toBe(false) expect(result.exitCode).toBe(0) expect(result.stdout.truncated).toBe(true) @@ -403,7 +434,7 @@ describe('killGroup', () => { }) it('swallows ESRCH for vanished groups', async () => { - const running = spawnProcess(spec('true')) + const running = spawnSubprocess(spec('true')) await running.done expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() }) @@ -412,7 +443,7 @@ describe('killGroup', () => { // Cleanup code commonly kills handles in a finally; after settlement the // group is gone and the pid may be reused, so a late kill must be inert // (no signal to a possibly-recycled pgid, no referenced timer delaying exit). - const running = spawnProcess(spec('true')) + const running = spawnSubprocess(spec('true')) await running.done const spy = vi.spyOn(process, 'kill') try { @@ -424,17 +455,137 @@ describe('killGroup', () => { }) }) +describe('stdio dispositions', () => { + it("'pipe' exposes raw streams for caller-owned protocol decoding", async () => { + const running = spawnSubprocess({ + ...spec('cat'), + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 1000 } }, + }) + expect(running.stdin).toBeDefined() + expect(running.stdout).toBeDefined() + expect(running.stderr).toBeUndefined() + expect(running.collected.stdout).toBeUndefined() + expect(running.collected.stderr).toBeDefined() + + const echoed = new Promise((resolve) => { + let text = '' + running.stdout!.on('data', (chunk: Buffer) => { text += chunk.toString('utf8') }) + running.stdout!.on('end', () => { resolve(text) }) + }) + running.stdin!.end('through the pipe\n') + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(await echoed).toBe('through the pipe\n') + }) + + it('a collect mode without spill keeps only the in-memory tail (no file)', async () => { + const running = spawnSubprocess({ + ...spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done'), + stdio: { stdin: 'ignore', stdout: { maxBytes: 100 }, stderr: { maxBytes: 100 } }, + }, { spillDir }) + await running.done + const read = running.collected.stdout!.readFrom(0) + expect(read.lossy).toBe(true) + expect(read.text).toContain('line-0200') + expect(read.spillPath).toBeUndefined() + }) +}) + +describe('dispose ladder', () => { + it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => { + const running = spawnSubprocess({ + ...spec('read -r line; exit 0'), + stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + }) + await running.dispose({ eofGraceMs: 5_000, graceMs: 200 }) + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(outcome.signal).toBeNull() + }) + + it('tier 2: an EOF-deaf child dies by SIGTERM', async () => { + const running = spawnSubprocess({ + ...spec('sleep 60'), + stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + }) + await running.dispose({ eofGraceMs: 100, graceMs: 5_000 }) + const outcome = await running.done + expect(outcome.signal).toBe('SIGTERM') + }) + + it('tier 3: a TERM-trapping child dies by SIGKILL, and dispose() is idempotent', async () => { + const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60')) + await waitForStdout(running, 'armed\n') + const first = running.dispose({ eofGraceMs: 50, graceMs: 200 }) + const second = running.dispose({ eofGraceMs: 50, graceMs: 200 }) + expect(second).toBe(first) + await first + const outcome = await running.done + expect(outcome.signal).toBe('SIGKILL') + }) +}) + +describe('windows tree semantics (injected platform)', () => { + it('kill and terminate route through taskkill by root pid', async () => { + const killed: number[] = [] + const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), { + spillDir, + platform: 'win32', + taskkill: (pid) => { + killed.push(pid) + // Simulate the forced tree termination taskkill performs. + try { + process.kill(pid, 'SIGKILL') + } catch { + // Already gone — matches taskkill's tolerated not-found status. + } + }, + }) + running.terminate() + const outcome = await running.done + expect(killed).toContain(running.pid) + expect(outcome.signal).toBe('SIGKILL') + }) + + it('waitForExit falls back to direct-child liveness where groups do not exist', async () => { + const running = spawnSubprocess(spec('true'), { spillDir, platform: 'win32', taskkill: () => {} }) + await running.done + await expect(running.waitForExit()).resolves.toBe(true) + }) +}) + +describe('waitForExit', () => { + it('waits for the whole detached tree, not just the shell', async () => { + const pidFile = join(spillDir, `tree-wait-${Date.now()}.pid`) + const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) + const grandchild = await waitForPidFile(pidFile) + running.terminate() + await running.done + await expect(running.waitForExit()).resolves.toBe(true) + await expect(waitGone(grandchild, 100)).resolves.toBeUndefined() + }) + + it('an aborted wait reports false while the tree lives', async () => { + const running = spawnSubprocess(spec('sleep 60')) + const controller = new AbortController() + controller.abort() + await expect(running.waitForExit(controller.signal)).resolves.toBe(false) + running.terminate() + await running.done + }) +}) + describe('argv validation', () => { it('rejects an empty argv before spawning', () => { - expect(() => spawnProcess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/) + expect(() => spawnSubprocess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/) }) it('rejects an empty program name before spawning', () => { - expect(() => spawnProcess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/) + expect(() => spawnSubprocess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/) }) it('spawns argv verbatim without shell interpretation', async () => { - const result = await spawnProcess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] }).done + const result = await finish(spawnSubprocess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] })) expect(result.stdout.text).toBe('$HOME') }) }) @@ -449,14 +600,14 @@ describe('abort edge cases', () => { addEventListener() {}, removeEventListener() {}, } as unknown as AbortSignal - expect(() => spawnProcess(spec('echo hi', { signal: bare }))) + expect(() => spawnSubprocess(spec('echo hi', { signal: bare }))) .toThrow(/aborted before spawn: aborted/) }) it('reports the terminating signal of an externally self-killed command', async () => { // spawnProcess reports the raw signal; whether it counts as timeout/cancel is the // executor's classification (a self-kill is neither) — see executor.spec.ts. - const result = await spawnProcess(spec('kill -TERM $$')).done + const result = await finish(spawnSubprocess(spec('kill -TERM $$'))) expect(result.signal).toBe('SIGTERM') }) }) @@ -467,7 +618,7 @@ describe('environment and spill-file hardening', () => { process.env.DSH_TEST_TOKEN = 'also-secret' process.env.DSH_TEST_PLAIN = 'visible' try { - const result = await spawnProcess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done + const result = await finish(spawnSubprocess(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"'))) expect(result.stdout.text.trim()).toBe('[absent|absent|absent]') } finally { delete process.env.DSH_TEST_API_KEY @@ -479,9 +630,9 @@ describe('environment and spill-file hardening', () => { it('injects only the current trusted DSH environment after scrubbing ambient values', async () => { process.env.DSH_STALE = 'old-value' try { - const result = await spawnProcess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { + const result = await finish(spawnSubprocess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' }, - })).done + }))) expect(result.stdout.text.trim()).toBe('[absent|1|current-session]') } finally { delete process.env.DSH_STALE @@ -489,21 +640,21 @@ describe('environment and spill-file hardening', () => { }) it('rejects DSH variables on the ordinary env channel', () => { - expect(() => spawnProcess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } }))) + expect(() => spawnSubprocess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } }))) .toThrow(/DSH_WRONG_CHANNEL.*dshEnv/) }) it('rejects ordinary variables on the managed env channel', () => { const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment - expect(() => spawnProcess(spec('true', { dshEnv: invalid }))) + expect(() => spawnSubprocess(spec('true', { dshEnv: invalid }))) .toThrow(/managed child env.*PATH.*use env/) }) it('creates spill files with owner-only permissions and random names', async () => { - const result = await spawnProcess( + const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, - ).done + )) const path = result.stdout.spillPath! expect(path).toMatch(/dsh-subprocess-\d+-\d+-[0-9a-f]{12}-stdout\.log$/) const mode = statSync(path).mode & 0o777 @@ -511,9 +662,9 @@ describe('environment and spill-file hardening', () => { }) it('defaults spills into a private per-process directory', async () => { - const result = await spawnProcess( + const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - ).done + )) const dir = dirname(result.stdout.spillPath!) expect(dir).toMatch(/dsh-subprocess-/) const mode = statSync(dir).mode & 0o777 @@ -533,7 +684,7 @@ describe('environment and spill-file hardening', () => { it('honors AbortSignal on background-style runs (no timeout)', async () => { const controller = new AbortController() - const running = spawnProcess(spec('sleep 60', { signal: controller.signal })) + const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort() }, 50) const result = await running.done expect(result.signal).toBe('SIGTERM') diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 890cf74e61..bbcca90133 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -1,14 +1,17 @@ /** - * The subprocess seam (`ctx.subprocess`): spawn fully-specified - * commands into managed process groups with bounded, spill-backed output and - * escalated kills. Command defaulting, shell semantics, deadlines, and - * presentation belong to consumers — the bash executor seam is the owning + * The subprocess seam (`ctx.subprocess`): spawn fully-specified commands into + * managed process trees with Node-shaped stdio dispositions — raw pipes for + * protocol streams, inherit for diagnostics, bounded spill-backed collection + * for batch output — plus tree-scoped signalling and a cooperative dispose + * ladder. Command defaulting, shell semantics, deadlines, framing, and + * presentation belong to consumers; the bash executor seam is the owning * template. The local implementation lives in * `@deepseek-ai/dsh-subprocess-local`. * @module @deepseek-ai/dsh-subprocess */ import { Context, Service } from 'cordis' +import { DSH_ENV_PREFIX } from './types.ts' import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' export { DSH_ENV_PREFIX } from './types.ts' @@ -16,13 +19,48 @@ export type { CollectedOutput, DshEnvironment, DshEnvironmentKey, + SubprocessCollect, + SubprocessCollectedOutputs, + SubprocessDisposeGraces, SubprocessHandle, SubprocessOutcome, + SubprocessOutputMode, SubprocessOutputRead, SubprocessOutputReader, SubprocessSpawnSpec, + SubprocessStdinMode, + SubprocessStdio, } from './types.ts' +/** + * Credential-shaped environment names are NOT forwarded to children (the + * harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a spawned + * process implicitly). One heuristic for every in-repo spawner; a + * deliberately supplied entry survives because explicit env layers merge + * after the scrub. + */ +export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i + +/** + * The ambient parent environment minus credential-shaped names and minus all + * `DSH_*` names — the canonical base every harness child starts from. `PATH`, + * `HOME`, locale, and proxy variables survive, so child CLIs run normally; + * harness identity never leaks implicitly (a child that needs current `DSH_*` + * facts receives them through {@link SubprocessSpawnSpec.dshEnv}, and a + * deliberately forwarded credential goes through an explicit env layer, which + * merges after this scrub). Exported as a plain function so spawners that + * cannot route through the service (node-pty backends, SDK-managed + * transports) share the one scrub definition. + * @returns a fresh environment object safe to hand to a child spawn. + */ +export function scrubbedParentEnv(): Record { + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value + } + return env +} + declare module 'cordis' { interface Context { subprocess: SubprocessService @@ -37,13 +75,17 @@ declare module 'cordis' { * * Implementations must honor these semantics: * - {@link spawn} returns immediately with a live handle; `done` resolves at - * process close and rejects only for spawn-level failures. - * - Output readers are offset-based and non-consuming, so independent readers - * never consume one another's output; lossy reads report truncation and the - * spill file holding the complete stream when one exists. - * - {@link SubprocessHandle.kill} and the spec's abort signal escalate - * SIGTERM→grace→SIGKILL across the whole process group. - * - Disposal kills all still-running managed processes and awaits their exit. + * process close with exit facts and rejects only for spawn-level failures. + * - Collect-mode readers are offset-based and non-consuming, so independent + * readers never consume one another's output; lossy reads report truncation + * and the spill file holding the complete stream when one exists. Piped + * streams are handed to the caller raw and never buffered here. + * - {@link SubprocessHandle.kill} signals without escalation, + * {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates + * SIGTERM→grace→SIGKILL, and {@link SubprocessHandle.dispose} runs the + * cooperative EOF-first ladder — all tree-scoped on every platform. + * - Disposal of the service terminates all still-running managed processes + * and awaits their exit. */ export abstract class SubprocessService extends Service { constructor(ctx: Context) { @@ -53,8 +95,8 @@ export abstract class SubprocessService extends Service { /** * Start one managed child process from a fully-specified spec; this seam * applies no defaults. - * @param spec - argv, directory, limits, grace, cancellation, and environment. - * @returns the live process handle (readers, kill, outcome promise). + * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment. + * @returns the live process handle (streams/readers, signalling, outcome promise). */ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle } diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index a5ec7f4e55..4c667919c7 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -1,11 +1,14 @@ /** - * Vocabulary for the subprocess seam: fully-specified spawn requests, - * bounded output with spill recovery, and live process handles. Command - * defaulting, shell semantics, and presentation belong to consumers such as - * the bash executor seam. + * Vocabulary for the subprocess seam: fully-specified spawn requests with + * Node-shaped per-stream stdio modes, bounded collected output with spill + * recovery, raw piped streams, and tree-scoped termination. Command + * defaulting, shell semantics, protocol framing, and presentation belong to + * consumers such as the bash executor seam. * @module dsh-subprocess/types */ +import type { Readable, Writable } from 'node:stream' + /** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ export const DSH_ENV_PREFIX = 'DSH_' as const @@ -26,60 +29,97 @@ export interface CollectedOutput { } /** - * A fully-specified spawn request. This seam applies no defaults: every limit - * and directory is explicit, so the caller's own config — not a hidden - * subprocess-service default — decides them (the `dsh-bash` request/spec split - * is the owning template). + * stdin disposition. `'ignore'` leaves fd 0 on `/dev/null`; `'pipe'` exposes + * {@link SubprocessHandle.stdin} for the caller's ongoing protocol writes; + * `{ data }` writes the bytes and closes (the batch shape). + */ +export type SubprocessStdinMode = 'ignore' | 'pipe' | { readonly data: string } + +/** + * Bounded in-memory collection for one output stream, with an optional + * full-stream spill file. Omitting `spill` keeps only the in-memory tail — + * the diagnostic-tail shape (a language server's stderr); including it makes + * the complete stream recoverable up to its cap (the bash tool shape). + */ +export interface SubprocessCollect { + /** In-memory cap in bytes; overflow keeps the TAIL. */ + maxBytes: number + /** Full-stream spill file; absent disables spilling entirely. */ + spill?: { + /** Whole-stream byte cap; a larger stream discards its now-incomplete spill. */ + maxBytes: number + } +} + +/** + * stdout/stderr disposition. `'pipe'` exposes the raw `Readable` for the + * caller's protocol decoding; `'inherit'` passes the parent's descriptor + * through (child diagnostics land on the harness's own stream); a + * {@link SubprocessCollect} object buffers boundedly with offset-based reads. + */ +export type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect + +/** Per-stream stdio dispositions, all explicit — this seam applies no defaults. */ +export interface SubprocessStdio { + stdin: SubprocessStdinMode + stdout: SubprocessOutputMode + stderr: SubprocessOutputMode +} + +/** + * A fully-specified spawn request. This seam applies no defaults: every + * disposition, limit, and directory is explicit, so the caller's own config — + * not a hidden subprocess-service default — decides them (the `dsh-bash` + * request/spec split is the owning template). */ export interface SubprocessSpawnSpec { /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ argv: readonly string[] /** Working directory for the child. */ cwd: string - /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */ - stdoutMaxBytes: number - /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ - stderrMaxBytes: number - /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ - maxSpillBytes: number - /** Grace period for kill escalation and for inherited pipes after process exit. */ + /** Per-stream stdio dispositions. */ + stdio: SubprocessStdio + /** + * Grace period in milliseconds for the {@link SubprocessHandle.terminate} + * escalation and for draining still-open collected pipes after the process + * exits (an inherited descriptor held by a surviving descendant cannot hold + * the outcome open indefinitely). + */ 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. + * Abort signal — starts the terminate escalation on the process tree 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}. + * Ordinary environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`). `DSH_*` names are rejected and + * belong in {@link dshEnv}; a deliberately forwarded credential-shaped + * entry survives because this layer merges after the scrub. */ env?: Record | 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. + * Harness-owned `DSH_*` variables for this execution. The scrubbed base has + * already discarded ambient `DSH_*` entries, so an unavailable current fact + * cannot inherit a stale value from the harness process; non-`DSH_*` names + * on this channel are rejected. */ dshEnv?: DshEnvironment | undefined } /** - * Raw outcome of one closed process. Deliberately carries NO timeout or - * cancellation classification: the service kills on abort but does not decide - * why — the caller reads the signal it owns to classify causes. + * Exit facts of one closed process — Node's `close`-event vocabulary. + * Deliberately carries NO timeout or cancellation classification (the caller + * reads the signal it owns to classify causes) and NO output: collected + * streams stay readable through {@link SubprocessHandle.collected} after + * settlement, so batch and streaming callers share one access path. */ export interface SubprocessOutcome { /** Exit code; null when the process died from a signal. */ exitCode: number | null /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ signal: NodeJS.Signals | null - stdout: CollectedOutput - stderr: CollectedOutput } /** One incremental {@link SubprocessOutputReader.readFrom} read. */ @@ -95,9 +135,11 @@ export interface SubprocessOutputRead { } /** - * Cursor-free incremental access to one live output stream. Offsets are + * Cursor-free incremental access to one collected output stream. Offsets are * whole-stream byte coordinates owned by the caller, so independent readers - * cannot consume one another's output. + * cannot consume one another's output; `readFrom(0)` after settlement is the + * batch result (`lossy` then means the in-memory tail lost its head — the + * {@link CollectedOutput.truncated} fact). */ export interface SubprocessOutputReader { /** @@ -110,19 +152,87 @@ export interface SubprocessOutputReader { readFrom(fromByte: number): SubprocessOutputRead } +/** Offset-based readers for the streams spawned in collect mode. */ +export interface SubprocessCollectedOutputs { + /** Present iff stdout is a {@link SubprocessCollect}. */ + readonly stdout?: SubprocessOutputReader + /** Present iff stderr is a {@link SubprocessCollect}. */ + readonly stderr?: SubprocessOutputReader +} + /** - * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL - * escalation; buffered output remains readable after exit. + * The two grace periods of the cooperative dispose ladder + * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted, + * validated Config fields, so teardown timing is deployment-tunable and this + * seam hardcodes nothing. + */ +export interface SubprocessDisposeGraces { + /** + * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce + * ON ITS OWN — flush durable state, tear down its own descendants — before + * escalation to platform termination. Usually WIDER than + * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven + * teardown may itself wait on a signal-trapping grandchild plus a final + * flush. + */ + eofGraceMs: number + /** + * Termination confirmation window (ms): POSIX applies it after `SIGTERM` + * and again after `SIGKILL`; Windows applies it after the forced tree + * termination. + */ + graceMs: number +} + +/** + * A live child process rooted in its own process tree. Collected output + * remains readable after exit; piped streams belong to the caller. + * + * Termination is tree-scoped everywhere: POSIX signals the detached process + * group (falling back to the direct child when the group is gone), Windows + * terminates the tree via `taskkill /T`, so helper processes cannot outlive + * the handle unnoticed. */ export interface SubprocessHandle { - /** Process id (group leader); -1 when the spawn itself failed. */ + /** Process id (tree root); -1 when the spawn itself failed. */ readonly pid: number - /** Live stdout reader (also readable after exit). */ - readonly stdout: SubprocessOutputReader - /** Live stderr reader (also readable after exit). */ - readonly stderr: SubprocessOutputReader - /** Resolves when the process closes; rejects only for spawn-level failures. */ + /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ + readonly stdin: Writable | undefined + /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ + readonly stdout: Readable | undefined + /** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */ + readonly stderr: Readable | undefined + /** Offset-based readers for collect-mode streams (also readable after exit). */ + readonly collected: SubprocessCollectedOutputs + /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ readonly done: Promise - /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ - kill(): void + /** + * Send one signal to the process tree, Node-style — no escalation, no + * timers. A no-op after the outcome has settled (the pid may be reused). + * @param signal - the signal to deliver (default `SIGTERM`; Windows + * force-terminates the tree for any value). + */ + kill(signal?: NodeJS.Signals): void + /** + * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree + * (Windows force-terminates immediately). Idempotent; also triggered by the + * spec's abort signal. + */ + terminate(): void + /** + * Wait until the process tree has exited — the tree, not just the direct + * child, so a still-running helper is observable before teardown returns. + * @param signal - optional bound for the wait. + * @returns `true` when the tree exited, `false` when the signal aborted first. + */ + waitForExit(signal?: AbortSignal): Promise + /** + * Tear the child down to quiescence, resolving only after exit: close stdin + * (when this handle owns a piped one) and allow cooperative flush for + * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced + * tree termination with a final bounded `graceMs` wait. + * @param graces - the ladder's two windows, from the consumer's Config. + * @throws when the child still has not exited `graceMs` after the forced tier. + */ + dispose(graces: SubprocessDisposeGraces): Promise } diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 630aa35f1a..d033e24a31 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { SubprocessService } from '@deepseek-ai/dsh-subprocess' -import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { scrubbedParentEnv, SubprocessService } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessDisposeGraces, SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** * Minimal concrete service: a hand-built handle. The seam is spawn-only — @@ -11,18 +11,20 @@ import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from class StubSubprocessService extends SubprocessService { spawn(spec: SubprocessSpawnSpec): SubprocessHandle { const read: SubprocessOutputRead = { text: '', nextOffset: 0, lossy: false } - let killed = false + const collected = spec.stdio.stdout !== 'pipe' && spec.stdio.stdout !== 'inherit' + ? { stdout: { readFrom: () => read } } + : {} return { pid: spec.argv.length, - stdout: { readFrom: () => read }, - stderr: { readFrom: () => read }, - done: Promise.resolve({ - exitCode: killed ? null : 0, - signal: null, - stdout: { text: 'ok', truncated: false }, - stderr: { text: '', truncated: false }, - }), - kill: () => { killed = true }, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected, + done: Promise.resolve({ exitCode: 0, signal: null }), + kill: () => {}, + terminate: () => {}, + waitForExit: () => Promise.resolve(true), + dispose: (_graces: SubprocessDisposeGraces) => Promise.resolve(), } } } @@ -34,22 +36,40 @@ describe('SubprocessService seam', () => { const handle = ctx.subprocess.spawn({ argv: ['true'], cwd: '/stub', - stdoutMaxBytes: 1, - stderrMaxBytes: 1, - maxSpillBytes: 1, + stdio: { stdin: 'ignore', stdout: { maxBytes: 1 }, stderr: 'inherit' }, graceMs: 1, }) expect(handle.pid).toBe(1) - expect(handle.stdout.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) + expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) handle.kill() + handle.terminate() + await expect(handle.waitForExit()).resolves.toBe(true) + await expect(handle.dispose({ eofGraceMs: 1, graceMs: 1 })).resolves.toBeUndefined() const outcome = await handle.done - expect(outcome.stdout.text).toBe('ok') + expect(outcome.exitCode).toBe(0) }) - it('loading a second implementation throws (one processes service per context — cordis standard)', async () => { + it('loading a second implementation throws (one subprocess service per context — cordis standard)', async () => { const ctx = new Context() await ctx.plugin(StubSubprocessService) - class SecondManager extends StubSubprocessService {} - await expect(ctx.plugin(SecondManager)).rejects.toThrow(/service "subprocess" has been registered/) + class SecondService extends StubSubprocessService {} + await expect(ctx.plugin(SecondService)).rejects.toThrow(/service "subprocess" has been registered/) + }) + + it('scrubbedParentEnv drops credential-shaped and DSH_ names but keeps PATH', () => { + process.env.DSH_SCRUB_PROBE = 'stale' + process.env.SCRUB_PROBE_TOKEN = 'secret' + process.env.SCRUB_PROBE_PLAIN = 'visible' + try { + const env = scrubbedParentEnv() + expect(env.DSH_SCRUB_PROBE).toBeUndefined() + expect(env.SCRUB_PROBE_TOKEN).toBeUndefined() + expect(env.SCRUB_PROBE_PLAIN).toBe('visible') + expect(env.PATH).toBeDefined() + } finally { + delete process.env.DSH_SCRUB_PROBE + delete process.env.SCRUB_PROBE_TOKEN + delete process.env.SCRUB_PROBE_PLAIN + } }) }) From e678db440e3818cfbb760db7e73aded5c4f7d6c2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:10:46 +0800 Subject: [PATCH 17/70] fix(subprocess): finish the rename in current-state docs and graph inputs Codex re-review (A): AGENTS.md layout tree, the architecture capability tables (en+zh), the seam Agent Note's group-name sentence (en+zh), and the SERVICE_ROLES entry still carried process/-era labels; regenerated the doc graphs and config catalog from the corrected inputs. --- .../2026-07-26-subprocess-seam.i18n.yaml | 4 ++-- .../architecture/2026-07-26-subprocess-seam.md | 2 +- .../architecture/2026-07-26-subprocess-seam.zh.md | 2 +- AGENTS.md | 2 +- docs/architecture.i18n.yaml | 4 ++-- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/capability-seams.md | 12 ++++++------ scripts/gen-doc-graphs.ts | 6 +++--- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index a0ea989893..4d639a40f2 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml @@ -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 -2026-07-26-subprocess-seam.md: cd7b18c209af0830e339abfe01aa91dc493e15da -2026-07-26-subprocess-seam.zh.md: 685d9797a7456b4edf95853b253dc97f3426c902 +2026-07-26-subprocess-seam.md: abfc43c1a960edd498b0960a191153008e39834c +2026-07-26-subprocess-seam.zh.md: 7fba975181b7871b34a8bc0e77d826e1ee2647f5 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md index cd7b18c209..abfc43c1a9 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -10,7 +10,7 @@ English | [中文](2026-07-26-subprocess-seam.zh.md) ## Decision -A new `process/` capability family owns "run and manage a process"; the bash family keeps "run a bash command" and consumes it: +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 caps, spill cap, 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 `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`): detached groups, tail-keep truncation with private bounded spill files, credential scrub with the two-channel `DSH_*` 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 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. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md index 685d9797a7..7fba975181 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -新的 `process/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: +新的 `subprocess/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: - **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、按流划分的上限、spill 上限、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `SubprocessHandle`、刻意不含超时/取消分类的 `SubprocessOutcome`,以及 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。 - **`@deepseek-ai/dsh-subprocess-local`(实现)**——`LocalSubprocessService`,构建在原 `run.ts` 管道(现为 `spawn.ts`)之上:detached 进程组、带私有有界 spill 文件的尾部保留截断、带双通道 `DSH_*` 合并的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 diff --git a/AGENTS.md b/AGENTS.md index abcd3e8709..82684b9e65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// prompt/ workspace instructions llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools - process/ child-process manager seam + local process-group impl + subprocess/ subprocess seam + local process-tree impl pty/ persistent PTY seam/backend/tools fs/ filesystem seam + local impl + policy gate + read/write/edit tools lsp/ language-server seam + local stdio provider + model-facing lsp tool diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 50abfe37ff..5c9c706d6c 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 -architecture.md: a1e169a9ef7717c6889f5a1aff53af984de565f4 -architecture.zh.md: 025d3b7343b36dca91cca9ebe6323628cb0702f9 +architecture.md: 9c4c9fc12a51c9c49d02a7aa9c3633ae7c95e4fe +architecture.zh.md: bc6adef969f60e7a7522a1877d29b2f90fc0c322 diff --git a/docs/architecture.md b/docs/architecture.md index a1e169a9ef..9c4c9fc12a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | -| `ctx.subprocess` | [`process/`](../packages/subprocess/README.md) | managed child-process groups under the bash executors | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process groups under the bash executors | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 025d3b7343..bc6adef969 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -28,7 +28,7 @@ | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力和会话表面压力 | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 | -| `ctx.subprocess` | [`process/`](../packages/subprocess/README.md) | bash 执行器之下受管理的子进程组 | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | bash 执行器之下受管理的子进程组 | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同一执行环境内的进程限制(argv 包装、逐调用策略) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 8e0169458f..5dc0a41a75 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -82,9 +82,9 @@ flowchart LR pkg_agent_spine_demo["agent-spine-demo"] pkg_goal["goal"] svc_goals["ctx.goals
Same-session goal domain"] - pkg_process["process"] - svc_subprocess["ctx.subprocess
Process manager seam"] - pkg_process_local["process-local"] + pkg_subprocess["subprocess"] + svc_subprocess["ctx.subprocess
Subprocess seam"] + pkg_subprocess_local["subprocess-local"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] pkg_bash["bash"] @@ -167,8 +167,6 @@ flowchart LR pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode - pkg_process --> svc_subprocess - pkg_process_local --> svc_subprocess pkg_pty --> svc_pty pkg_pty_local --> svc_pty pkg_sandbox --> svc_sandbox @@ -196,6 +194,8 @@ flowchart LR pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents pkg_subagent_spawn --> svc_subagents + pkg_subprocess --> svc_subprocess + pkg_subprocess_local --> svc_subprocess pkg_system_prompt --> svc_systemPrompt pkg_tasks --> svc_tasks pkg_tasks_local --> svc_tasks @@ -324,7 +324,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | -| `ctx.subprocess` | `seam` | `process` | `process-local` | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | - | The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | - | The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 6924cc2761..526e2ea5cd 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -267,10 +267,10 @@ const SERVICE_ROLES: ServiceRole[] = [ }, { key: 'subprocess', - pkg: 'process', - title: 'Process manager seam', + pkg: 'subprocess', + title: 'Subprocess seam', mode: 'seam', - implementations: ['process-local'], + implementations: ['subprocess-local'], consumers: ['bash-local', 'bash-sandbox'], note: 'The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation.', }, From 3672cd25b4a2234f668675bf97463983a18a0995 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:27:59 +0800 Subject: [PATCH 18/70] feat(subprocess): migrate lsp-local, subagent-acp, and the env scrubs onto the seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...026-07-26-subprocess-consumer-migration.md | 38 ++ .../2026-07-26-subprocess-seam.md | 4 +- docs/capability-seams.md | 7 +- docs/config-catalog.md | 7 +- docs/cordis-catalog/services.md | 14 +- docs/core-data-structures/subprocess.md | 225 +++++++--- docs/module-graph.md | 19 +- .../fixtures/subagent/subagent-acp/cordis.yml | 4 + knip.json | 32 +- packages/bash/bash-local/src/index.ts | 2 + .../cordis/tool-cordis/src/api-catalog.ts | 32 +- packages/lsp/lsp-local/package.json | 3 + packages/lsp/lsp-local/src/connection.ts | 246 +++-------- packages/lsp/lsp-local/src/index.ts | 20 +- packages/lsp/lsp-local/src/instance.ts | 7 +- packages/lsp/lsp-local/tests/built-lib.e2e.ts | 5 +- .../lsp/lsp-local/tests/connection.spec.ts | 91 +--- packages/lsp/lsp-local/tests/instance.spec.ts | 7 +- .../lsp/lsp-local/tests/lifecycle.spec.ts | 4 + packages/lsp/lsp-local/tests/provider.spec.ts | 13 + .../lsp-local/tests/typescript-server.e2e.ts | 2 + packages/lsp/lsp-local/tsconfig.json | 3 + .../lsp/tool-lsp/tests/integration.spec.ts | 2 + packages/mcp/mcp-client/package.json | 2 + packages/mcp/mcp-client/src/transport.ts | 17 +- packages/mcp/mcp-client/tsconfig.json | 3 + packages/pty/pty-local/package.json | 2 + packages/pty/pty-local/src/index.ts | 9 +- packages/pty/pty-local/tsconfig.json | 3 + packages/sdk/helper/package.json | 2 + .../src/package-managers/package-manager.ts | 11 +- packages/sdk/helper/tsconfig.json | 3 + packages/subagent/README.md | 3 +- packages/subagent/subagent-acp/README.md | 2 +- packages/subagent/subagent-acp/package.json | 5 +- packages/subagent/subagent-acp/src/index.ts | 3 +- packages/subagent/subagent-acp/src/run.ts | 54 ++- .../subagent-acp/tests/subagent-acp.e2e.ts | 2 +- .../subagent-acp/tests/subagent-acp.spec.ts | 53 ++- packages/subagent/subagent-acp/tsconfig.json | 2 +- .../subagent/subagent-subprocess/README.md | 53 --- .../subagent/subagent-subprocess/package.json | 37 -- .../subagent/subagent-subprocess/src/index.ts | 223 ---------- .../subagent-subprocess/src/invariant.ts | 30 -- .../tests/subagent-subprocess.spec.ts | 389 ------------------ .../subagent-subprocess/tsconfig.json | 15 - packages/subprocess/README.md | 6 +- .../subprocess/subprocess-local/README.md | 10 +- .../subprocess/subprocess-local/src/spawn.ts | 18 +- .../subprocess-local/tests/spawn.spec.ts | 143 ++++++- packages/subprocess/subprocess/README.md | 17 +- pnpm-lock.yaml | 34 +- python/sdk-runtime/package.json | 1 - scripts/gen-doc-graphs.ts | 4 +- scripts/type-equiv.manifest.json | 30 ++ .../verify-package-readme-model-experience.ts | 1 - tsconfig.host.json | 1 - 57 files changed, 750 insertions(+), 1225 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md delete mode 100644 packages/subagent/subagent-subprocess/README.md delete mode 100644 packages/subagent/subagent-subprocess/package.json delete mode 100644 packages/subagent/subagent-subprocess/src/index.ts delete mode 100644 packages/subagent/subagent-subprocess/src/invariant.ts delete mode 100644 packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts delete mode 100644 packages/subagent/subagent-subprocess/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md new file mode 100644 index 0000000000..805f27ba2e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md @@ -0,0 +1,38 @@ +# Agent Note: The subprocess seam goes Node-shaped and every eligible spawner rides it + +Status: implemented + +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, 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 + +The seam's vocabulary is now Node-shaped, and every spawner that can ride the service does: + +- **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, split Node-style**: `kill(signal?)` sends one signal and is a no-op after settlement; `terminate()` owns the SIGTERM→grace→SIGKILL escalation (and serves the spec's abort signal); `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); `dispose(graces)` is the cooperative stdin-EOF→SIGTERM→SIGKILL ladder absorbed from `subagent-subprocess`, memoized per handle. Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. +- **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork) and mcp-client (the MCP SDK owns the transport spawn) — import the function, so environment policy is single-sourced even where process ownership is not; 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 `handle.dispose` 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). + +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 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. + +**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, the dispose ladder, 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. + +Cost: the seam is wider — three stdio modes and four termination verbs instead of one of each — 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/test-support spawns remain outside the service by ownership, with the scrub as the shared floor. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md index abfc43c1a9..5cf0e59660 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -12,7 +12,7 @@ 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 caps, spill cap, 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 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` types. `argv` is never shell-interpreted. +- **`@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 two-channel `DSH_*` 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 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. - **`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: 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 ships proven against its one real consumer family, per the shape-interfaces-around-current-consumers rule; the others are named as deferred work in the seam README. +**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). **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. diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 5dc0a41a75..f1e473767d 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -87,6 +87,8 @@ flowchart LR pkg_subprocess_local["subprocess-local"] pkg_bash_local["bash-local"] pkg_bash_sandbox["bash-sandbox"] + pkg_lsp_local["lsp-local"] + pkg_subagent_acp["subagent-acp"] pkg_bash["bash"] svc_bash["ctx.bash
Bash executor seam"] svc_bashEnv["ctx.bashEnv
Managed bash environment registry"] @@ -116,7 +118,6 @@ flowchart LR svc_subagents["ctx.subagents
Subagent provider registry"] pkg_subagent_spawn["subagent-spawn"] pkg_subagent_fork["subagent-fork"] - pkg_subagent_acp["subagent-acp"] pkg_tool_ralph["tool-ralph"] pkg_tasks["tasks"] svc_tasks["ctx.tasks
Background task registry"] @@ -270,6 +271,8 @@ flowchart LR svc_subagents --> pkg_tool_subagent svc_subprocess --> pkg_bash_local svc_subprocess --> pkg_bash_sandbox + svc_subprocess --> pkg_lsp_local + svc_subprocess --> pkg_subagent_acp svc_systemPrompt --> pkg_agent_loop svc_systemPrompt --> pkg_tool_fs svc_systemPrompt --> pkg_tool_pty @@ -324,7 +327,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | -| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | - | The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation. | +| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | | `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. | | `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. | | `ctx.pty` | `seam` | [`pty`](../packages/pty/pty) | [`pty-local`](../packages/pty/pty-local) | [`tool-pty`](../packages/pty/tool-pty) | - | The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a21b11378d..4f90714ebf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -716,7 +716,7 @@ Source: [`packages/llm/llm-retry/src/index.ts:39`](../packages/llm/llm-retry/src ## `@deepseek-ai/dsh-lsp-local` -Requires: `lsp` +Requires: `lsp` · `subprocess` ```ts config-catalog /** Plugin configuration: provider id → local language-server configuration. */ @@ -752,7 +752,7 @@ export interface LspLocalServerConfig { } ``` -Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src/index.ts) +Source: [`packages/lsp/lsp-local/src/index.ts:87`](../packages/lsp/lsp-local/src/index.ts) ## `@deepseek-ai/dsh-mcp-client` @@ -1286,7 +1286,7 @@ Source: [`packages/storage/storage-sqlite/src/index.ts:24`](../packages/storage/ ## `@deepseek-ai/dsh-subagent-acp` -Requires: `subagents` +Requires: `subagents` · `subprocess` ```ts config-catalog /** Config: how to spawn and drive the child ACP agent process. */ @@ -2108,6 +2108,5 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm` ([`packages/session-title/session-title-llm/src/index.ts`](../packages/session-title/session-title-llm/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) -- `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) - `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts)) - `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4108332e43..0f7f83a331 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1565,24 +1565,24 @@ Abstract subprocess service. Subclass, implement spawn, and load the subclass as Implementations must honor these semantics: -- spawn returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures. -- Output readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. -- SubprocessHandle.kill and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole process group. -- Disposal kills all still-running managed processes and awaits their exit. +- spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures. +- Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. +- SubprocessHandle.kill signals without escalation, SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, and SubprocessHandle.dispose runs the cooperative EOF-first ladder — all tree-scoped on every platform. +- Disposal of the service terminates all still-running managed processes and awaits their exit. ```ts cordis-catalog /** * Start one managed child process from a fully-specified spec; this seam * applies no defaults. - * @param spec - argv, directory, limits, grace, cancellation, and environment. - * @returns the live process handle (readers, kill, outcome promise). + * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment. + * @returns the live process handle (streams/readers, signalling, outcome promise). */ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle ``` Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) -Source: [`packages/subprocess/subprocess/src/index.ts:48`](../../packages/subprocess/subprocess/src/index.ts) +Source: [`packages/subprocess/subprocess/src/index.ts:90`](../../packages/subprocess/subprocess/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index 6e7cea3990..ae0b291b88 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -1,12 +1,12 @@ # Subprocess -The subprocess seam is split across interface ([dsh-subprocess](../../packages/subprocess/subprocess), `ctx.subprocess`) and implementation ([dsh-subprocess-local](../../packages/subprocess/subprocess-local)); its consumers are other capability seams — today the [bash executor family](bash.md), which passes `['bash', '-c', command]` argv and owns every default. This seam owns the managed `DSH_*` environment namespace and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports them so bash consumers keep one import root. +The subprocess seam is split across interface ([dsh-subprocess](../../packages/subprocess/subprocess), `ctx.subprocess`) and implementation ([dsh-subprocess-local](../../packages/subprocess/subprocess-local)); its consumers are other capability seams and out-of-process backends — the [bash executor family](bash.md) (collect-mode batch output), the LSP host (piped protocol streams + a collected stderr tail), and the ACP subagent backend (piped protocol streams + inherited stderr). This seam owns the managed `DSH_*` environment namespace, the shared credential scrub (`scrubbedParentEnv`), and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports the vocabulary so bash consumers keep one import root. Source: [`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts) ## Managed environment namespace and captured output -`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before merging the caller's snapshot, and each captured stream reports its truncation and spill-recovery state through `CollectedOutput`. +`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before merging the caller's snapshot, and each collected stream reports its truncation and spill-recovery state through `CollectedOutput`. ```ts type-equiv /** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ @@ -30,83 +30,170 @@ interface CollectedOutput { } ``` -## The fully-explicit spawn spec +## Node-shaped stdio dispositions -The seam applies no defaults: every limit and directory is explicit on the spec, so the caller's own config — not a hidden subprocess-service default — decides them. `argv` is never shell-interpreted. +Each stream's disposition is explicit, chosen per consumer: raw pipes for protocol framing (LSP JSON-RPC, ACP ndjson), inherit for pass-through diagnostics, and collect mode for bounded batch output — with the spill file optional, so a diagnostic tail (a language server's stderr) buffers without leaving files behind. ```ts type-equiv /** - * A fully-specified spawn request. This seam applies no defaults: every limit - * and directory is explicit, so the caller's own config — not a hidden - * subprocess-service default — decides them (the `dsh-bash` request/spec split - * is the owning template). + * stdin disposition. `'ignore'` leaves fd 0 on `/dev/null`; `'pipe'` exposes + * {@link SubprocessHandle.stdin} for the caller's ongoing protocol writes; + * `{ data }` writes the bytes and closes (the batch shape). + */ +type SubprocessStdinMode = 'ignore' | 'pipe' | { readonly data: string } +``` + +```ts type-equiv +/** + * Bounded in-memory collection for one output stream, with an optional + * full-stream spill file. Omitting `spill` keeps only the in-memory tail — + * the diagnostic-tail shape (a language server's stderr); including it makes + * the complete stream recoverable up to its cap (the bash tool shape). + */ +interface SubprocessCollect { + /** In-memory cap in bytes; overflow keeps the TAIL. */ + maxBytes: number + /** Full-stream spill file; absent disables spilling entirely. */ + spill?: { + /** Whole-stream byte cap; a larger stream discards its now-incomplete spill. */ + maxBytes: number + } +} +``` + +```ts type-equiv +/** + * stdout/stderr disposition. `'pipe'` exposes the raw `Readable` for the + * caller's protocol decoding; `'inherit'` passes the parent's descriptor + * through (child diagnostics land on the harness's own stream); a + * {@link SubprocessCollect} object buffers boundedly with offset-based reads. + */ +type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect +``` + +```ts type-equiv +/** Per-stream stdio dispositions, all explicit — this seam applies no defaults. */ +interface SubprocessStdio { + stdin: SubprocessStdinMode + stdout: SubprocessOutputMode + stderr: SubprocessOutputMode +} +``` + +## The fully-explicit spawn spec + +The seam applies no defaults: every disposition, limit, and directory is explicit on the spec, so the caller's own config — not a hidden subprocess-service default — decides them. `argv` is never shell-interpreted. + +```ts type-equiv +/** + * A fully-specified spawn request. This seam applies no defaults: every + * disposition, limit, and directory is explicit, so the caller's own config — + * not a hidden subprocess-service default — decides them (the `dsh-bash` + * request/spec split is the owning template). */ interface SubprocessSpawnSpec { /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ argv: readonly string[] /** Working directory for the child. */ cwd: string - /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */ - stdoutMaxBytes: number - /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ - stderrMaxBytes: number - /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ - maxSpillBytes: number - /** Grace period for kill escalation and for inherited pipes after process exit. */ + /** Per-stream stdio dispositions. */ + stdio: SubprocessStdio + /** + * Grace period in milliseconds for the {@link SubprocessHandle.terminate} + * escalation and for draining still-open collected pipes after the process + * exits (an inherited descriptor held by a surviving descendant cannot hold + * the outcome open indefinitely). + */ 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. + * Abort signal — starts the terminate escalation on the process tree 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}. + * Ordinary environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`). `DSH_*` names are rejected and + * belong in {@link dshEnv}; a deliberately forwarded credential-shaped + * entry survives because this layer merges after the scrub. */ env?: Record | 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. + * Harness-owned `DSH_*` variables for this execution. The scrubbed base has + * already discarded ambient `DSH_*` entries, so an unavailable current fact + * cannot inherit a stale value from the harness process; non-`DSH_*` names + * on this channel are rejected. */ dshEnv?: DshEnvironment | undefined } ``` -## Handles and offset-based reads +## Handles: streams, readers, and tree-scoped termination -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 spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `kill(signal)` sends one signal Node-style, `terminate()` escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need. ```ts type-equiv /** - * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL - * escalation; buffered output remains readable after exit. + * A live child process rooted in its own process tree. Collected output + * remains readable after exit; piped streams belong to the caller. + * + * Termination is tree-scoped everywhere: POSIX signals the detached process + * group (falling back to the direct child when the group is gone), Windows + * terminates the tree via `taskkill /T`, so helper processes cannot outlive + * the handle unnoticed. */ interface SubprocessHandle { - /** Process id (group leader); -1 when the spawn itself failed. */ + /** Process id (tree root); -1 when the spawn itself failed. */ readonly pid: number - /** Live stdout reader (also readable after exit). */ - readonly stdout: SubprocessOutputReader - /** Live stderr reader (also readable after exit). */ - readonly stderr: SubprocessOutputReader - /** Resolves when the process closes; rejects only for spawn-level failures. */ + /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ + readonly stdin: Writable | undefined + /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ + readonly stdout: Readable | undefined + /** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */ + readonly stderr: Readable | undefined + /** Offset-based readers for collect-mode streams (also readable after exit). */ + readonly collected: SubprocessCollectedOutputs + /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ readonly done: Promise - /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ - kill(): void + /** + * Send one signal to the process tree, Node-style — no escalation, no + * timers. A no-op after the outcome has settled (the pid may be reused). + * @param signal - the signal to deliver (default `SIGTERM`; Windows + * force-terminates the tree for any value). + */ + kill(signal?: NodeJS.Signals): void + /** + * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree + * (Windows force-terminates immediately). Idempotent; also triggered by the + * spec's abort signal. + */ + terminate(): void + /** + * Wait until the process tree has exited — the tree, not just the direct + * child, so a still-running helper is observable before teardown returns. + * @param signal - optional bound for the wait. + * @returns `true` when the tree exited, `false` when the signal aborted first. + */ + waitForExit(signal?: AbortSignal): Promise + /** + * Tear the child down to quiescence, resolving only after exit: close stdin + * (when this handle owns a piped one) and allow cooperative flush for + * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced + * tree termination with a final bounded `graceMs` wait. + * @param graces - the ladder's two windows, from the consumer's Config. + * @throws when the child still has not exited `graceMs` after the forced tier. + */ + dispose(graces: SubprocessDisposeGraces): Promise } ``` ```ts type-equiv /** - * Cursor-free incremental access to one live output stream. Offsets are + * Cursor-free incremental access to one collected output stream. Offsets are * whole-stream byte coordinates owned by the caller, so independent readers - * cannot consume one another's output. + * cannot consume one another's output; `readFrom(0)` after settlement is the + * batch result (`lossy` then means the in-memory tail lost its head — the + * {@link CollectedOutput.truncated} fact). */ interface SubprocessOutputReader { /** @@ -134,26 +221,62 @@ interface SubprocessOutputRead { } ``` -## Outcomes carry no cause classification - -`done` reports raw exit facts. The service 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). +```ts type-equiv +/** Offset-based readers for the streams spawned in collect mode. */ +interface SubprocessCollectedOutputs { + /** Present iff stdout is a {@link SubprocessCollect}. */ + readonly stdout?: SubprocessOutputReader + /** Present iff stderr is a {@link SubprocessCollect}. */ + readonly stderr?: SubprocessOutputReader +} +``` ```ts type-equiv /** - * Raw outcome of one closed process. Deliberately carries NO timeout or - * cancellation classification: the service kills on abort but does not decide - * why — the caller reads the signal it owns to classify causes. + * The two grace periods of the cooperative dispose ladder + * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted, + * validated Config fields, so teardown timing is deployment-tunable and this + * seam hardcodes nothing. + */ +interface SubprocessDisposeGraces { + /** + * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce + * ON ITS OWN — flush durable state, tear down its own descendants — before + * escalation to platform termination. Usually WIDER than + * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven + * teardown may itself wait on a signal-trapping grandchild plus a final + * flush. + */ + eofGraceMs: number + /** + * Termination confirmation window (ms): POSIX applies it after `SIGTERM` + * and again after `SIGKILL`; Windows applies it after the forced tree + * termination. + */ + graceMs: number +} +``` + +## Outcomes carry exit facts only + +`done` reports Node's close-event vocabulary and no cause classification — the service kills on abort but never decides why (the caller reads the deadline signal it owns, e.g. the bash executor's `timedOut`/`aborted` split). Collected output stays readable through `handle.collected` after settlement, so batch and streaming callers share one access path. + +```ts type-equiv +/** + * Exit facts of one closed process — Node's `close`-event vocabulary. + * Deliberately carries NO timeout or cancellation classification (the caller + * reads the signal it owns to classify causes) and NO output: collected + * streams stay readable through {@link SubprocessHandle.collected} after + * settlement, so batch and streaming callers share one access path. */ interface SubprocessOutcome { /** Exit code; null when the process died from a signal. */ exitCode: number | null /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ signal: NodeJS.Signals | null - stdout: CollectedOutput - stderr: CollectedOutput } ``` ## Service behavior -The abstract [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam defines `spawn` only; [`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) is the local implementation (detached groups, tail-keep spill-backed collection, credential scrub, kill-and-join disposal). See [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md) for the seam contract and [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md) for the mechanics. +The abstract [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam defines `spawn` only; [`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) is the local implementation (detached trees, per-disposition wiring, credential scrub, terminate-and-join disposal). See [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md) for the seam contract and [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md) for the mechanics. diff --git a/docs/module-graph.md b/docs/module-graph.md index f310cac386..435411beb0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -64,7 +64,6 @@ flowchart TD pkg_subagent_fork["subagent-fork"] pkg_subagent_inprocess["subagent-inprocess"] pkg_subagent_spawn["subagent-spawn"] - pkg_subagent_subprocess["subagent-subprocess"] pkg_tool_subagent["tool-subagent"] end subgraph group_web["packages/web"] @@ -229,7 +228,6 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_skill --> pkg_invariants - pkg_subagent_subprocess --> pkg_invariants pkg_acp_snapshot --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants @@ -273,6 +271,7 @@ flowchart TD pkg_client_ui_workspace --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants + pkg_helper --> pkg_subprocess pkg_telemetry --> pkg_brand pkg_telemetry --> pkg_invariants pkg_telemetry --> pkg_paths @@ -359,6 +358,7 @@ flowchart TD pkg_lsp_local --> pkg_invariants pkg_lsp_local --> pkg_llm pkg_lsp_local --> pkg_lsp + pkg_lsp_local --> pkg_subprocess pkg_lsp_local --> pkg_timeout pkg_sandbox_local --> pkg_invariants pkg_sandbox_local --> pkg_llm @@ -517,6 +517,7 @@ flowchart TD pkg_pty_local --> pkg_sandbox pkg_pty_local --> pkg_sandbox_policy pkg_pty_local --> pkg_session + pkg_pty_local --> pkg_subprocess pkg_tasks_local --> pkg_agent pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks @@ -654,6 +655,7 @@ flowchart TD pkg_tool_lsp --> pkg_tools pkg_mcp_client --> pkg_invariants pkg_mcp_client --> pkg_llm + pkg_mcp_client --> pkg_subprocess pkg_mcp_client --> pkg_tools pkg_tool_pty --> pkg_agent pkg_tool_pty --> pkg_invariants @@ -680,7 +682,7 @@ flowchart TD pkg_subagent_acp --> pkg_llm pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent - pkg_subagent_acp --> pkg_subagent_subprocess + pkg_subagent_acp --> pkg_subprocess pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm @@ -819,7 +821,6 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | -| [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | @@ -846,7 +847,7 @@ flowchart TD | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | +| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -875,7 +876,7 @@ flowchart TD | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | +| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -911,7 +912,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | -| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session) | +| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -934,11 +935,11 @@ flowchart TD | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml index 3bd5f5393c..c69cf574f7 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/cordis.yml @@ -10,6 +10,10 @@ - id: subagent name: '@deepseek-ai/dsh-subagent' +# The out-of-process ACP backend spawns its child through the subprocess seam. +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: subagent-acp name: '@deepseek-ai/dsh-subagent-acp' config: diff --git a/knip.json b/knip.json index 110abb3a2b..09b9544a36 100644 --- a/knip.json +++ b/knip.json @@ -6,7 +6,8 @@ "ignoreBinaries": [ "bwrap", "python3", - "sandbox-exec" + "sandbox-exec", + "taskkill" ], "ignoreWorkspaces": [ "vendor/*", @@ -262,8 +263,14 @@ ] }, "packages/session-query/session-query-sqlite": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/code-runtime/code-runtime-worker": { "entry": [ @@ -316,8 +323,14 @@ ] }, "packages/session-persistence/session-checkpoint-policy": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/util/paths": { "entry": [ @@ -488,15 +501,6 @@ "tests/**/*.ts" ] }, - "packages/subagent/subagent-subprocess": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/fs/tool-fs": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 9d9ed676e2..f93c17e2ed 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -160,9 +160,11 @@ export class LocalBashExecutor extends BashExecutor { /** The collect-mode readers the executor itself requested (present by construction). */ private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } { const { stdout, stderr } = handle.collected + /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */ if (stdout === undefined || stderr === undefined) { throw new Error('bash-local: subprocess implementation dropped a requested collect stream') } + /* v8 ignore stop */ return { stdout, stderr } } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index ec0d279bab..7d7ce8e5af 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -750,7 +750,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle', - jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, limits, grace, cancellation, and environment.\n * @returns the live process handle (readers, kill, outcome promise).\n */', + jsDoc: '/**\n * Start one managed child process from a fully-specified spec; this seam\n * applies no defaults.\n * @param spec - argv, directory, stdio dispositions, grace, cancellation, and environment.\n * @returns the live process handle (streams/readers, signalling, outcome promise).\n */', }, ], }, @@ -2210,13 +2210,29 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentStopReasonMap', declaration: 'export interface SubagentStopReasonMap {\n completed: \'completed\';\n aborted: \'aborted\';\n error: \'error\';\n \'max-tokens\': \'max-tokens\';\n refusal: \'refusal\';\n}', }, + { + name: 'SubprocessCollect', + declaration: 'export interface SubprocessCollect {\n maxBytes: number;\n spill?: {\n maxBytes: number;\n };\n}', + }, + { + name: 'SubprocessCollectedOutputs', + declaration: 'export interface SubprocessCollectedOutputs {\n readonly stdout?: SubprocessOutputReader;\n readonly stderr?: SubprocessOutputReader;\n}', + }, + { + name: 'SubprocessDisposeGraces', + declaration: 'export interface SubprocessDisposeGraces {\n eofGraceMs: number;\n graceMs: number;\n}', + }, { name: 'SubprocessHandle', - declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdout: SubprocessOutputReader;\n readonly stderr: SubprocessOutputReader;\n readonly done: Promise;\n kill(): void;\n}', + declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n kill(signal?: NodeJS.Signals): void;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n dispose(graces: SubprocessDisposeGraces): Promise;\n}', }, { name: 'SubprocessOutcome', - declaration: 'export interface SubprocessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n stdout: CollectedOutput;\n stderr: CollectedOutput;\n}', + declaration: 'export interface SubprocessOutcome {\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n}', + }, + { + name: 'SubprocessOutputMode', + declaration: 'export type SubprocessOutputMode = \'pipe\' | \'inherit\' | SubprocessCollect;', }, { name: 'SubprocessOutputRead', @@ -2228,7 +2244,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubprocessSpawnSpec', - declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdoutMaxBytes: number;\n stderrMaxBytes: number;\n maxSpillBytes: number;\n graceMs: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n}', + declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n}', + }, + { + name: 'SubprocessStdinMode', + declaration: 'export type SubprocessStdinMode = \'ignore\' | \'pipe\' | {\n readonly data: string;\n};', + }, + { + name: 'SubprocessStdio', + declaration: 'export interface SubprocessStdio {\n stdin: SubprocessStdinMode;\n stdout: SubprocessOutputMode;\n stderr: SubprocessOutputMode;\n}', }, { name: 'SurfaceEvent', diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json index 6815b1c917..1d6499d9f8 100644 --- a/packages/lsp/lsp-local/package.json +++ b/packages/lsp/lsp-local/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-lsp": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -42,6 +43,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-lsp": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7", "typescript": "^6.0.3", diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 1103c4dbd2..cbad79ea0d 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -1,16 +1,17 @@ /** - * A JSON-RPC endpoint over one spawned language server's stdio. Owns id correlation, outbound - * requests/notifications, and inbound server→client requests: it answers `workspace/configuration` - * from static config, and rejects `workspace/applyEdit` (this host never applies edits or runs - * commands). It caps stderr, surfaces framing/decoder failures as a fatal close, and exposes the - * child handle so the instance owns process-signal teardown. + * A JSON-RPC endpoint over one language server spawned through the subprocess + * seam. Owns id correlation, outbound requests/notifications, and inbound + * server→client requests: it answers `workspace/configuration` from static + * config, and rejects `workspace/applyEdit` (this host never applies edits or + * runs commands). It caps stderr, surfaces framing/decoder failures as a + * fatal close, and exposes tree-scoped termination through the handle so the + * instance owns teardown; group/tree mechanics live in the seam's + * implementation. * @module @deepseek-ai/dsh-lsp-local/connection */ -import type { ChildProcessByStdio } from 'node:child_process' -import { spawn, spawnSync } from 'node:child_process' -import type { Readable, Writable } from 'node:stream' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import type { Writable } from 'node:stream' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { encodeMessage, MessageDecoder } from './framing.ts' /** How to launch the server and answer its config requests. */ @@ -27,6 +28,12 @@ export interface ConnectionSpec { readonly maxMessageBytes: number /** Largest stderr tail retained for diagnostics. */ readonly maxStderrBytes: number + /** + * Bound (ms) for draining pipes a surviving helper still holds after the + * server exits; the instance passes its kill grace so exit observation is + * never slower than the escalation it feeds. + */ + readonly pipeDrainGraceMs: number /** Static answer to every `workspace/configuration` item. */ readonly configuration: unknown } @@ -48,178 +55,89 @@ export type ConnectionWriter = ( done: (error?: Error | null) => void, ) => void -/** Host operations used to signal a detached process tree. */ -export interface ProcessTreeOperations { - /** Signal a POSIX process group. */ - readonly signal: (target: number, signal: NodeJS.Signals) => void - /** Signal the direct child when POSIX group signaling is unavailable. */ - readonly killChild: (signal: NodeJS.Signals) => void - /** Terminate a Windows process tree by root pid. */ - readonly taskkill: (pid: number) => void -} - -/** Narrow taskkill runner result used by the Windows process-tree adapter. */ -export interface TaskkillResult { - /** Process exit status, or null when spawning failed. */ - readonly status: number | null - /** Spawn failure, when the executable could not run. */ - readonly error?: Error -} - -/** Invoke a command synchronously for the Windows taskkill adapter. */ -export type TaskkillRunner = ( - command: string, - args: string[], - options: { stdio: 'ignore' }, -) => TaskkillResult - -/** Invoke the host process-signal primitive for a POSIX process group. */ -export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => boolean - -const processSignalRunner: ProcessSignalRunner = process.kill.bind(process) - -/** taskkill status for "process not found": the requested process tree is already absent. */ -const TASKKILL_TREE_NOT_FOUND_STATUS = 128 +/** Spawn one subprocess for this connection (the provider passes `ctx.subprocess.spawn`). */ +export type ConnectionSpawner = (spec: SubprocessSpawnSpec) => SubprocessHandle const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => { stdin.write(encodeMessage(message), done) } -/** - * Terminate one Windows process tree and wait for taskkill to finish. - * @param pid - root process id. - * @param run - command runner; tests inject results without requiring Windows. - */ -export function taskkillProcessTree( - pid: number, - run: TaskkillRunner = spawnSync, -): void { - const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) - if (result.error !== undefined) throw result.error - if (result.status === TASKKILL_TREE_NOT_FOUND_STATUS) return - if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`) -} - -/** - * Signal one POSIX process group through an injectable host primitive. - * @param target - negative process-group id. - * @param signal - requested signal. - * @param run - host signal runner; tests inject it without touching real processes. - */ -export function signalProcessGroup( - target: number, - signal: NodeJS.Signals, - run: ProcessSignalRunner = processSignalRunner, -): void { - run(target, signal) -} - -/** - * Wait until a process-tree liveness probe reports exit. - * @param isAlive - process-tree liveness probe. - * @param signal - optional bound for the wait. - * @param yieldNow - event-loop yield primitive. - * @returns `true` when the tree exited, or `false` when the signal aborted first. - */ -export async function waitForTreeExit( - isAlive: () => boolean, - signal?: AbortSignal, - yieldNow: () => Promise = yieldToEventLoop, -): Promise { - while (isAlive()) { - if (signal?.aborted) return false - await yieldNow() - } - return true -} - -/** - * Signal a detached process tree with platform-correct semantics. POSIX falls back to the direct - * child; Windows requires taskkill to reach the full tree. - * @param platform - host platform. - * @param pid - detached root process id. - * @param signal - requested termination signal. - * @param operations - host operations. - */ -export function signalProcessTree( - platform: NodeJS.Platform, - pid: number, - signal: NodeJS.Signals, - operations: ProcessTreeOperations, -): void { - if (platform === 'win32') { - operations.taskkill(pid) - return - } - try { - operations.signal(-pid, signal) - } catch { - try { - operations.killChild(signal) - } catch { - // The direct child already exited; teardown remains idempotent. - } - } -} - /** A live JSON-RPC endpoint bound to one child process. */ export class LspConnection { - private readonly child: ChildProcessByStdio + private readonly handle: SubprocessHandle + private readonly stdin: Writable private readonly decoder: MessageDecoder private readonly pending = new Map() private nextId = 1 - private stderr = Buffer.alloc(0) private closeReason: Error | undefined /** Set once the process has fully exited; the instance awaits it during teardown. */ readonly closed: Promise /** * @param spec - how to launch the server and answer its config requests. + * @param spawner - the subprocess seam's spawn (the provider passes `ctx.subprocess.spawn`). * @param onServerRequest - answers a server→client request; rejects to send an error response. * @param writer - message writer; tests inject callback failures without relying on OS pipe races. */ constructor( - private readonly spec: ConnectionSpec, + spec: ConnectionSpec, + spawner: ConnectionSpawner, private readonly onServerRequest: (method: string, params: unknown) => Promise, private readonly writer: ConnectionWriter = writeConnectionMessage, ) { this.decoder = new MessageDecoder(spec.maxMessageBytes) - // `detached` gives teardown a process-tree root: POSIX signals its negative process-group id, - // while Windows passes the root pid to taskkill /T so helpers such as tsserver cannot outlive it. - this.child = spawn(spec.command, [...spec.args], { + // stdin/stdout are piped protocol streams this endpoint frames itself; + // stderr is a collected diagnostic tail (no spill — the bounded tail IS + // the contract). The seam owns detachment and tree-scoped signalling. + this.handle = spawner({ + argv: [spec.command, ...spec.args], cwd: spec.cwd, + stdio: { + stdin: 'pipe', + stdout: 'pipe', + stderr: { maxBytes: spec.maxStderrBytes }, + }, + graceMs: spec.pipeDrainGraceMs, env: spec.env, - stdio: ['pipe', 'pipe', 'pipe'], - detached: true, }) + /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ + if (this.handle.stdin === undefined || this.handle.stdout === undefined) { + throw new Error('lsp-local: subprocess implementation dropped a piped protocol stream') + } + /* v8 ignore stop */ + this.stdin = this.handle.stdin this.closed = new Promise((resolve) => { - this.child.on('close', () => { + const close = (): void => { const reason = this.closeReason ?? new Error(this.exitMessage()) // Record the reason so any request issued AFTER close rejects immediately instead of hanging // (a closed process sends no further responses). this.closeReason = reason this.failAll(reason) resolve() + } + this.handle.done.then(close, (error: unknown) => { + // A spawn-level failure never produces a close event; the rejection is + // the fatal cause and the close boundary at once. + this.fail(asError(error)) + close() }) }) - this.child.on('error', (error) => { this.fail(error) }) // Child stdin can fail while the process itself remains alive (for example, a server closes fd // 0). Treat that as a fatal connection error so pending requests reject immediately instead of // waiting for a process-close event that may never arrive. - this.child.stdin.on('error', (error) => { this.fail(error) }) - this.child.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) }) - this.child.stderr.on('data', (chunk: Buffer) => { this.onStderr(chunk) }) + this.stdin.on('error', (error) => { this.fail(error) }) + this.handle.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) }) } /** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */ get pid(): number { - /* v8 ignore next -- the `-1` fallback only applies to a spawn that produced no pid; defensive. */ - return this.child.pid ?? -1 + return this.handle.pid } /** The retained stderr tail, for diagnostics on a failed server. */ get stderrTail(): string { - return this.stderr.toString('utf8') + /* v8 ignore next -- the collect disposition always exposes a stderr reader; defensive. */ + return this.handle.collected.stderr?.readFrom(0).text ?? '' } /** Whether the transport has failed even if the child close event has not arrived yet. */ @@ -289,14 +207,14 @@ export class LspConnection { return this.nextId } - /** Request termination of the server's process tree. */ + /** Request termination of the server's process tree (SIGTERM, no escalation). */ terminate(): void { - this.signalTree('SIGTERM') + this.handle.kill('SIGTERM') } /** Force termination of the server's process tree. */ kill(): void { - this.signalTree('SIGKILL') + this.handle.kill('SIGKILL') } /** @@ -305,39 +223,7 @@ export class LspConnection { * @returns `true` when the tree exited, or `false` when the signal aborted first. */ async waitForProcessTreeExit(signal?: AbortSignal): Promise { - return await waitForTreeExit(this.processTreeAlive.bind(this), signal) - } - - /** Signal the whole process tree. */ - private signalTree(sig: NodeJS.Signals): void { - const pid = this.child.pid - if (pid === undefined) return - signalProcessTree(process.platform, pid, sig, { - signal: signalProcessGroup, - killChild: this.child.kill.bind(this.child), - taskkill: taskkillProcessTree, - }) - } - - /** Whether the detached tree's root or POSIX process group is still alive. */ - private processTreeAlive(): boolean { - const pid = this.child.pid - /* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */ - if (pid === undefined) return false - try { - process.kill(-pid, 0) - return true - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - /* v8 ignore next -- POSIX reports an absent group as ESRCH, but child-reaping timing makes - whether lifecycle tests observe this branch platform-dependent. */ - if (code === 'ESRCH') return false - /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs - process-group lifecycle tests on POSIX hosts where absence reports ESRCH. */ - if (code === 'EPERM') return true - return this.child.exitCode === null && this.child.signalCode === null - /* v8 ignore stop */ - } + return await this.handle.waitForExit(signal) } private onStdout(chunk: Buffer): void { @@ -348,28 +234,12 @@ export class LspConnection { // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and // SIGKILL the whole group so helper processes don't outlive the leader. this.fail(asError(error)) - this.signalTree('SIGKILL') + this.handle.kill('SIGKILL') return } for (const message of messages) this.dispatch(message) } - private onStderr(chunk: Buffer): void { - // Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just - // before it exits, so the final bounded segment is the useful one. - const cap = this.spec.maxStderrBytes - if (chunk.length >= cap) { - // Copy the bounded suffix so retaining it does not pin an arbitrarily large incoming buffer. - this.stderr = Buffer.from(chunk.subarray(chunk.length - cap)) - return - } - const retainedBytes = Math.min(this.stderr.length, cap - chunk.length) - this.stderr = Buffer.concat([ - this.stderr.subarray(this.stderr.length - retainedBytes), - chunk, - ], retainedBytes + chunk.length) - } - private dispatch(message: unknown): void { if (message === null || typeof message !== 'object') return const frame = message as Record @@ -423,7 +293,7 @@ export class LspConnection { reject(error) } try { - this.writer(this.child.stdin, message, done) + this.writer(this.stdin, message, done) /* v8 ignore start -- Node stream write failures are callback-delivered; this guards a nonconforming Writable implementation throwing synchronously. */ } catch (error) { diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index dda3558130..b9699706aa 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -25,6 +25,8 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { abortable, abortError } from './abort.ts' import { canonicalizeWorkspace, readHostSource } from './host.ts' import { LspInstance } from './instance.ts' +import type { ConnectionSpawner } from './connection.ts' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { InstanceSpec } from './instance.ts' export { canonicalizeWorkspace, readHostSource } from './host.ts' @@ -44,10 +46,10 @@ export { LspConnection } from './connection.ts' export const name = 'lsp-local' /** Services required by this plugin. */ -export const inject = ['lsp'] +export const inject = ['lsp', 'subprocess'] /** Credential-shaped ambient env vars are NOT forwarded to the child by default. */ -const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i + const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000 const DEFAULT_MAX_STDERR_BYTES = 1_000_000 @@ -127,7 +129,7 @@ export function apply(ctx: Context, config: Config): void { validateServerConfig(providerId, resolved) const childEnv = buildChildEnv(resolved.env) const executable = resolveExecutable(resolved.command, childEnv) - return new LocalLspProvider(providerId, resolved, childEnv, executable) + return new LocalLspProvider(providerId, resolved, childEnv, executable, spec => ctx.subprocess.spawn(spec)) }) ctx.effect(() => { @@ -189,6 +191,7 @@ class LocalLspProvider implements LspProvider { private readonly config: ResolvedServerConfig, private readonly childEnv: Record, private readonly executable: string, + private readonly spawner: ConnectionSpawner, ) { this.id = LspProviderId(providerId) this.extensionToLanguage = config.extensionToLanguage @@ -282,10 +285,12 @@ class LocalLspProvider implements LspProvider { initializationOptions: this.config.initializationOptions, maxMessageBytes: this.config.maxMessageBytes, maxStderrBytes: this.config.maxStderrBytes, + // Exit observation must never be slower than the escalation it feeds. + pipeDrainGraceMs: this.config.killGraceMs, shutdownTimeoutMs: this.config.shutdownTimeoutMs, killGraceMs: this.config.killGraceMs, } - return new LspInstance(spec) + return new LspInstance(spec, this.spawner) } /** Dispose every live instance and block further queries. */ @@ -302,12 +307,9 @@ class LocalLspProvider implements LspProvider { } } -/** The ambient env minus credential-shaped vars, plus the config's explicit env. */ +/** The seam's scrubbed parent env (credential-shaped and DSH_* names dropped), plus the config's explicit env. */ function buildChildEnv(extra: Record): Record { - const scrubbed = Object.entries(process.env).filter( - ([key, value]) => value !== undefined && !SENSITIVE_ENV_PATTERN.test(key), - ) as [string, string][] - return { ...Object.fromEntries(scrubbed), ...extra } + return { ...scrubbedParentEnv(), ...extra } } /** diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 266dd3c59f..0718bb6b82 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -17,7 +17,7 @@ import type { import { deadline } from '@deepseek-ai/dsh-timeout' import { abortable, abortError } from './abort.ts' import { LspConnection } from './connection.ts' -import type { ConnectionSpec, ConnectionWriter } from './connection.ts' +import type { ConnectionSpawner, ConnectionSpec, ConnectionWriter } from './connection.ts' import type { HostSource } from './host.ts' import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts' import { @@ -67,10 +67,11 @@ export class LspInstance { /** * @param spec - the launch, initialize, and teardown parameters. + * @param spawner - the subprocess seam's spawn function. * @param writer - optional connection writer used by transport conformance tests. */ - constructor(private readonly spec: InstanceSpec, writer?: ConnectionWriter) { - this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params), writer) + constructor(private readonly spec: InstanceSpec, spawner: ConnectionSpawner, writer?: ConnectionWriter) { + this.connection = new LspConnection(spec, spawner, (method, params) => this.answerServerRequest(method, params), writer) this.ready = this.initialize() // A handshake rejection must not surface as an unhandled rejection before the first query awaits // it; queries attach the real handler. diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts index a2da86d87c..de9777269a 100644 --- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -16,7 +16,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' const pkgDir = fileURLToPath(new URL('..', import.meta.url)) const seamLib = join(pkgDir, '../lsp/lib/index.js') -const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) +const subprocessLib = join(pkgDir, '../../subprocess/subprocess-local/lib/index.js') +const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) && existsSync(subprocessLib) const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -41,8 +42,10 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { const { Context } = await import('cordis') const { default: Lsp } = await import('@deepseek-ai/dsh-lsp') const LspLocal = await import('@deepseek-ai/dsh-lsp-local') + const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local') const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LspLocal, { servers: { fake: { diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index aa7e819cb6..cdb331077c 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -1,18 +1,8 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { fileURLToPath } from 'node:url' import { LspConnection } from '@deepseek-ai/dsh-lsp-local' -import { - signalProcessGroup, - signalProcessTree, - taskkillProcessTree, - waitForTreeExit, -} from '@deepseek-ai/dsh-lsp-local/src/connection.ts' -import type { - ConnectionWriter, - ProcessSignalRunner, - ProcessTreeOperations, - TaskkillRunner, -} from '@deepseek-ai/dsh-lsp-local/src/connection.ts' +import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts' +import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -42,8 +32,9 @@ function connect( env: { ...process.env as Record, ...env }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, + pipeDrainGraceMs: 3_000, configuration: { setting: 42 }, - }, (method, params) => { + }, spawnSubprocess, (method, params) => { seen?.push({ method, params }) return onServerRequest(method, params) }) @@ -151,8 +142,9 @@ function connectScript(script: string, maxStderrBytes = 100_000, writer?: Connec env: { ...process.env as Record }, maxMessageBytes: 16_000_000, maxStderrBytes, + pipeDrainGraceMs: 3_000, configuration: null, - }, () => Promise.resolve(null), writer) + }, spawnSubprocess, () => Promise.resolve(null), writer) open.push(conn) return conn } @@ -166,8 +158,9 @@ describe('LspConnection edge behavior', () => { env: {}, maxMessageBytes: 1000, maxStderrBytes: 1000, + pipeDrainGraceMs: 3_000, configuration: null, - }, () => Promise.resolve(null)) + }, spawnSubprocess, () => Promise.resolve(null)) open.push(conn) await expect(conn.request('initialize', {})).rejects.toThrow() }) @@ -248,72 +241,6 @@ describe('LspConnection edge behavior', () => { }) }) -describe('process-tree signaling', () => { - it('forwards POSIX process-group signals through the host runner', () => { - const run: ProcessSignalRunner = vi.fn(() => true) - signalProcessGroup(-42, 'SIGKILL', run) - expect(run).toHaveBeenCalledWith(-42, 'SIGKILL') - }) - - it('waits for tree exit and stops when its bound aborts', async () => { - const isAlive = vi.fn() - .mockReturnValueOnce(true) - .mockReturnValue(false) - const yieldNow = vi.fn(() => Promise.resolve()) - await expect(waitForTreeExit(isAlive, undefined, yieldNow)).resolves.toBe(true) - expect(yieldNow).toHaveBeenCalledOnce() - - const controller = new AbortController() - controller.abort() - await expect(waitForTreeExit(() => true, controller.signal, yieldNow)).resolves.toBe(false) - }) - - it('uses taskkill for a Windows tree and a negative pid for a POSIX group', () => { - const operations = fakeProcessTreeOperations() - signalProcessTree('win32', 42, 'SIGTERM', operations) - expect(operations.taskkill).toHaveBeenCalledWith(42) - expect(operations.signal).not.toHaveBeenCalled() - - signalProcessTree('linux', 42, 'SIGKILL', operations) - expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL') - }) - - it('surfaces a Windows taskkill failure without downgrading to the direct child', () => { - const fallback = fakeProcessTreeOperations() - vi.mocked(fallback.taskkill).mockImplementation(() => { throw new Error('taskkill unavailable') }) - expect(() => { signalProcessTree('win32', 42, 'SIGTERM', fallback) }).toThrow(/taskkill unavailable/) - expect(fallback.killChild).not.toHaveBeenCalled() - }) - - it('tolerates a POSIX tree-signaling race after the direct child is already gone', () => { - const posixGone = fakeProcessTreeOperations() - vi.mocked(posixGone.signal).mockImplementation(() => { throw new Error('group gone') }) - vi.mocked(posixGone.killChild).mockImplementation(() => { throw new Error('child gone') }) - expect(() => { signalProcessTree('linux', 42, 'SIGKILL', posixGone) }).not.toThrow() - }) - - it('runs taskkill for the full tree, accepts an absent tree, and rejects command failures', () => { - const success: TaskkillRunner = vi.fn(() => ({ status: 0 })) - taskkillProcessTree(42, success) - expect(success).toHaveBeenCalledWith('taskkill', ['/PID', '42', '/T', '/F'], { stdio: 'ignore' }) - - expect(() => { taskkillProcessTree(42, () => ({ status: 128 })) }).not.toThrow() - - const spawnFailure = new Error('cannot spawn taskkill') - expect(() => { taskkillProcessTree(42, () => ({ status: null, error: spawnFailure })) }).toThrow(spawnFailure) - expect(() => { taskkillProcessTree(42, () => ({ status: 1 })) }).toThrow(/status 1/) - }) -}) - -/** Create observable process-tree operations without touching host processes. */ -function fakeProcessTreeOperations(): ProcessTreeOperations { - return { - signal: vi.fn(), - killChild: vi.fn(), - taskkill: vi.fn(), - } -} - /** Poll a predicate until it holds or a deadline elapses. */ async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { const start = Date.now() diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 9f246e602a..48dbd0252a 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -9,6 +9,7 @@ import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' +import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -43,10 +44,11 @@ function makeInstance( initializationOptions: { init: true }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, + pipeDrainGraceMs: 200, shutdownTimeoutMs: 200, killGraceMs: 200, ...overrides, - }, writer) + }, spawnSubprocess, writer) live.push(instance) return instance } @@ -72,10 +74,11 @@ function scriptInstance(script: string, overrides: Partial = {}): initializationOptions: null, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, + pipeDrainGraceMs: 150, shutdownTimeoutMs: 150, killGraceMs: 150, ...overrides, - }) + }, spawnSubprocess) live.push(instance) return instance } diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 8a556a01e9..ccd85a1e14 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -7,6 +7,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url' import { Context } from 'cordis' import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' import { deadline } from '@deepseek-ai/dsh-timeout' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' @@ -45,6 +46,7 @@ async function mount( ): Promise { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) const register = ctx.lsp.registerProvider.bind(ctx.lsp) const registrationSpy = captureProvider === undefined ? undefined @@ -76,6 +78,7 @@ describe('lsp-local end to end over a fake server', () => { await writeFile(join(ws, 'a.py'), 'x = 1\n') const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LspLocal, { servers: { typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }), @@ -318,6 +321,7 @@ describe('lsp-local end to end over a fake server', () => { it('rejects at load when the command is not found', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, { servers: { missing: { diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 829a84264b..77fcbe7851 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -3,6 +3,7 @@ import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { Context } from 'cordis' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' @@ -42,6 +43,7 @@ describe('lsp-local provider resolution', () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('onpath', { command: 'fake-lsp', args: [], @@ -54,6 +56,7 @@ describe('lsp-local provider resolution', () => { it('skips empty PATH segments and fails when the command is absent', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('nope', { command: 'fake-lsp', args: [], @@ -67,6 +70,7 @@ describe('lsp-local provider resolution', () => { // Use a server that never emits results and dispose the plugin, then confirm queries are refused. const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) // Grab the provider instance by registering, then dispose the whole plugin fiber. const lsp = ctx.lsp const fiber = await ctx.plugin(LspLocal, config('disp', { @@ -83,6 +87,7 @@ describe('lsp-local provider resolution', () => { it('rejects a nonpositive teardown budget at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('bad-budget', { command: process.execPath, args: ['-e', ''], @@ -95,6 +100,7 @@ describe('lsp-local provider resolution', () => { it('rejects a nonpositive byte cap at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('bad-cap', { command: process.execPath, args: ['-e', ''], @@ -107,6 +113,7 @@ describe('lsp-local provider resolution', () => { it.each(['shutdownTimeoutMs', 'killGraceMs'] as const)('rejects %s above Node timer range at load', async (name) => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('bad-timer', { command: process.execPath, args: ['-e', ''], @@ -122,6 +129,7 @@ describe('lsp-local provider resolution', () => { await writeFile(notExe, 'plain text, not executable') const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('abs-bad', { command: notExe, args: [], @@ -133,6 +141,7 @@ describe('lsp-local provider resolution', () => { it('rejects an executable directory as a command at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('abs-directory', { command: ws, args: [], @@ -144,6 +153,7 @@ describe('lsp-local provider resolution', () => { it('rejects an empty server table at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/) await ctx.fiber.dispose() }) @@ -151,6 +161,7 @@ describe('lsp-local provider resolution', () => { it('rejects an empty server id at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, config('', { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' }, @@ -161,6 +172,7 @@ describe('lsp-local provider resolution', () => { it('resolves every executable before publishing any provider', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, { servers: { valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, @@ -174,6 +186,7 @@ describe('lsp-local provider resolution', () => { it('rolls back earlier registrations when a later server conflicts', async () => { const ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(LspLocal, { servers: { first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, diff --git a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts index 8ba61c0717..fe9230ce14 100644 --- a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts +++ b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts @@ -10,6 +10,7 @@ import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' @@ -52,6 +53,7 @@ beforeAll(async () => { ctx = new Context() await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LspLocal, { servers: { typescript: { diff --git a/packages/lsp/lsp-local/tsconfig.json b/packages/lsp/lsp-local/tsconfig.json index 3a631ae288..2b106ddc81 100644 --- a/packages/lsp/lsp-local/tsconfig.json +++ b/packages/lsp/lsp-local/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../lsp" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../support/invariants" } diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index 0a0265b490..4d790cbd6b 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url' import { Context } from 'cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import Lsp from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy' @@ -49,6 +50,7 @@ async function mount(hang: boolean, timeoutMs?: number): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(Lsp) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LspLocal, { servers: { inline: { diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 3480b48e52..0d214de9e8 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -40,6 +41,7 @@ "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@modelcontextprotocol/server-everything": "^2026.7.4", "@modelcontextprotocol/server-filesystem": "^2026.7.4", diff --git a/packages/mcp/mcp-client/src/transport.ts b/packages/mcp/mcp-client/src/transport.ts index 6f7c584b20..f4bed91931 100644 --- a/packages/mcp/mcp-client/src/transport.ts +++ b/packages/mcp/mcp-client/src/transport.ts @@ -9,22 +9,17 @@ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { Config } from './index.ts' /** - * Credential-shaped ambient env vars are NOT forwarded to the child by default - * (the parent harness's own secrets must not leak into a spawned process - * implicitly). Same pattern as `dsh-subagent-acp`. + * The subprocess seam's scrubbed parent env (credential-shaped and stale + * `DSH_*` names dropped), plus the spec's explicit env. The MCP SDK owns the + * actual spawn, so this transport shares the scrub definition rather than the + * spawn path. */ -const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i - -/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */ function buildChildEnv(extra: Record): Record { - const env: Record = {} - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key)) env[key] = value - } - return { ...env, ...extra } + return { ...scrubbedParentEnv(), ...extra } } /** diff --git a/packages/mcp/mcp-client/tsconfig.json b/packages/mcp/mcp-client/tsconfig.json index 668ee2c3cb..461b250297 100644 --- a/packages/mcp/mcp-client/tsconfig.json +++ b/packages/mcp/mcp-client/tsconfig.json @@ -21,6 +21,9 @@ { "path": "../../core/tools" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../support/invariants" } diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json index fb26d845e5..e86024516e 100644 --- a/packages/pty/pty-local/package.json +++ b/packages/pty/pty-local/package.json @@ -36,6 +36,7 @@ "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -49,6 +50,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts index b466ecfc46..d768d6bfa8 100644 --- a/packages/pty/pty-local/src/index.ts +++ b/packages/pty/pty-local/src/index.ts @@ -10,6 +10,7 @@ import type { IPtyForkOptions } from 'node-pty' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' @@ -26,7 +27,6 @@ export const name = 'pty-local' /** Required services: PTY registry plus the one shared confinement policy. */ export const inject = ['pty', 'sandbox', 'sandboxPolicy'] -const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i interface SandboxModeFenceState { pty: Context['pty'] sandboxPolicy: Context['sandboxPolicy'] @@ -56,12 +56,9 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void { } function childEnvironment(spec: PtyBackendSpawnSpec): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {} - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith('DSH_')) env[key] = value - } + // node-pty owns the spawn; the base env shares the subprocess seam's scrub. return { - ...env, + ...scrubbedParentEnv(), TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', diff --git a/packages/pty/pty-local/tsconfig.json b/packages/pty/pty-local/tsconfig.json index 45a03248db..1580e4c8a2 100644 --- a/packages/pty/pty-local/tsconfig.json +++ b/packages/pty/pty-local/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../sandbox/sandbox-policy" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../support/invariants" } diff --git a/packages/sdk/helper/package.json b/packages/sdk/helper/package.json index 1d9c6a2be2..2884e04f72 100644 --- a/packages/sdk/helper/package.json +++ b/packages/sdk/helper/package.json @@ -35,6 +35,7 @@ "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -44,6 +45,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/sdk/helper/src/package-managers/package-manager.ts b/packages/sdk/helper/src/package-managers/package-manager.ts index 8d6b617977..3d67d194d3 100644 --- a/packages/sdk/helper/src/package-managers/package-manager.ts +++ b/packages/sdk/helper/src/package-managers/package-manager.ts @@ -5,6 +5,7 @@ */ import { execFile, spawn } from 'node:child_process' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import { promisify } from 'node:util' import type { PackageJsonFile } from '../documents/package-json-file.ts' import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts' @@ -51,8 +52,14 @@ export async function probePackageManagerVersion(name: PackageManagerName, cwd: } } -/** Remove credential-shaped environment variables from spawned commands. */ -export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { +/** + * Remove credential-shaped environment variables from spawned commands. + * @param environment - source environment (injectable for tests); the default + * path shares the subprocess seam's scrub so every harness spawner drops the + * same names. + */ +export function scrubEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (environment === undefined) return scrubbedParentEnv() return Object.fromEntries(Object.entries(environment).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/i.test(name))) } diff --git a/packages/sdk/helper/tsconfig.json b/packages/sdk/helper/tsconfig.json index 18e79898c7..b1a3b7a61a 100644 --- a/packages/sdk/helper/tsconfig.json +++ b/packages/sdk/helper/tsconfig.json @@ -33,6 +33,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../support/invariants" } diff --git a/packages/subagent/README.md b/packages/subagent/README.md index ccc6ab9cba..e03072fa78 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -8,10 +8,9 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `subagent-inprocess/` | Shared in-process run driver (no provider; one cleanup effect per run) | — | | `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) | | `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | -| `subagent-subprocess/` | Shared out-of-process machinery: env scrub, dispose ladder, isolated config dirs (pure lib; registers nothing) | — | | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs). Tests replace only the child boundary with package-local fixtures. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend spawns its child through the [`subprocess/`](../subprocess/README.md) seam (the shared credential scrub, tree-scoped teardown, and dispose ladder). Tests replace only the child boundary with package-local fixtures. The proposal and design rationale: [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 45d9abe2b8..991773dadd 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -55,7 +55,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th ## Process boundary -The child environment is built by [`buildChildEnv`](../subagent-subprocess/README.md): credential-shaped ambient variables are removed, then explicit `config.env` values are applied. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. +The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 2564afa8da..b06a5e50ea 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -32,7 +32,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-subagent-subprocess": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "dependencies": { @@ -47,7 +47,8 @@ "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index f8b3cd78c6..45605a8f2c 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -15,7 +15,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } fro import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts' export const name = 'subagent-acp' -export const inject = ['subagents'] +export const inject = ['subagents', 'subprocess'] /** Config: how to spawn and drive the child ACP agent process. */ export interface Config { @@ -152,6 +152,7 @@ class AcpProvider implements SubagentProvider { env: this.config.env, disposeEofGraceMs: this.config.disposeEofGraceMs, disposeGraceMs: this.config.disposeGraceMs, + spawn: spec => this.ctx.subprocess.spawn(spec), onError: (error, stopReason) => { // The seam forbids `result` rejecting, so a child-level failure is // flattened to a stop reason — preserve it here rather than losing it. diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 730505df84..aa9cd4f492 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -8,9 +8,8 @@ * @module @deepseek-ai/dsh-subagent-acp/run */ -import { spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' -import { Readable, Writable } from 'node:stream' +import { Readable as NodeReadable, Writable as NodeWritable } from 'node:stream' import { ClientSideConnection, ndJsonStream, @@ -26,7 +25,7 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' -import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** Fixed response to child permission requests: reject by default, or select the first allow option. */ export type PermissionPolicy = 'allow' | 'reject' @@ -47,9 +46,9 @@ export interface AcpRunSpec { permission: PermissionPolicy /** * Extra environment variables to ADD for the child (e.g. the child harness's - * `DEEPSEEK_API_KEY`). Merged on top of the scrubbed ambient env — see - * {@link buildChildEnv}. A value here is forwarded even if its name matches - * the credential-scrub pattern (an explicit opt-in for the child's own creds). + * `DEEPSEEK_API_KEY`). Merged on top of the subprocess seam's scrubbed + * parent env. A value here is forwarded even if its name matches the + * credential-scrub pattern (an explicit opt-in for the child's own creds). */ env: Record /** @@ -65,6 +64,12 @@ export interface AcpRunSpec { * fills this from its `disposeGraceMs` config. */ disposeGraceMs: number + /** + * Spawn function from the subprocess seam (`ctx.subprocess.spawn`), so the + * child rides the shared scrub, tree-scoped teardown, and service-owned + * lifetime instead of a package-local child_process path. + */ + spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle /** * Sink for a child-level failure that the run flattened into a stop reason * (the seam contract forbids `result` rejecting). The driver calls this with @@ -159,20 +164,33 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // each other or with a local agent that happens to use the same session id. const id = SessionId(randomUUID()) - // Keep diagnostics on parent stderr; only ACP output contributes to the result. - const child = spawn(spec.command, spec.args, { + // Keep diagnostics on parent stderr ('inherit'); only ACP output contributes + // to the result. The seam's scrub drops ambient credentials while spec.env + // (the child's own key) merges after it. + const child = spec.spawn({ + argv: [spec.command, ...spec.args], cwd: spec.cwd, - env: buildChildEnv(spec.env), - stdio: ['pipe', 'pipe', 'inherit'], + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + graceMs: spec.disposeGraceMs, + env: spec.env, }) - // Capture the child-process error event immediately. - const spawnFailed = spawnFailure(child) + /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ + if (child.stdin === undefined || child.stdout === undefined) { + throw new Error('subagent-acp: subprocess implementation dropped a piped protocol stream') + } + /* v8 ignore stop */ + // Spawn-level failure surfaces as `done` rejecting into the startup race; a + // clean exit must never win it, so the success arm parks forever. + /* v8 ignore start -- the success arm's never-settling executor is intentionally empty. */ + const spawnFailed: Promise = child.done.then(() => new Promise(() => {}), (err: unknown) => Promise.reject(toError(err))) + /* v8 ignore stop */ + spawnFailed.catch(() => { /* observed by the startup race; never unhandled */ }) // Startup rollback and the published handle share one process teardown. let processDisposal: Promise | undefined - const disposeProcess = (): Promise => (processDisposal ??= disposeChildProcess(child, { - disposeEofGraceMs: spec.disposeEofGraceMs, - disposeGraceMs: spec.disposeGraceMs, + const disposeProcess = (): Promise => (processDisposal ??= child.dispose({ + eofGraceMs: spec.disposeEofGraceMs, + graceMs: spec.disposeGraceMs, })) // Accumulate the child's streamed assistant text — the SubagentResult output. @@ -207,8 +225,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe const conn = new ClientSideConnection( makeClient, ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, + NodeWritable.toWeb(child.stdin) as WritableStream, + NodeReadable.toWeb(child.stdout) as ReadableStream, ), ) @@ -252,7 +270,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe sessionId = returnedSessionId if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started') })(), - spawnFailed.then((err): never => { throw err }), + spawnFailed, cancelSettled.then((): never => { throw new Error('subagent cancelled before the ACP session started') }), ]) } catch (error: unknown) { diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index 6f5a1e1c54..b3909b708d 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -21,7 +21,7 @@ const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cord const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) // How to launch the child acp-agent (src via tsx / lib via plain node, per DSH_EXAMPLE_MODE). -// buildChildEnv scrubs ambient creds but keeps these extras, so the model key is +// The subprocess seam scrubs ambient creds while spec.env merges after it, so the model key is // forwarded explicitly; TSX_TSCONFIG_PATH is added by the resolver in src mode only. const childLaunch = resolveExampleLaunch({ srcBin: binScript, diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 79ef8831cf..919e736cb8 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -6,10 +6,11 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' -import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess' import type { Agent } from '@deepseek-ai/dsh-agent' import * as acp from '../src/index.ts' import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' /** * Keyless integration tests for the ACP subagent backend. Each spawns a REAL @@ -41,6 +42,7 @@ interface SetupEnv { async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'reject') { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, @@ -98,19 +100,23 @@ describe('acpContentText / toAcpPrompt', () => { }) }) -describe('buildChildEnv', () => { - it('drops credential-shaped ambient vars but keeps the explicit extras', () => { - process.env.DSH_ACP_TEST_SECRET_TOKEN = 'leak-me' +describe('child env layering (through the subprocess seam)', () => { + it('drops credential-shaped ambient vars but keeps the explicit extras', async () => { + process.env.ACP_TEST_AMBIENT_SECRET_TOKEN = 'leak-me' try { - const env = buildChildEnv({ DEEPSEEK_API_KEY: 'explicit' }) - // The credential-shaped ambient var is scrubbed. - expect(env.DSH_ACP_TEST_SECRET_TOKEN).toBeUndefined() - // The explicitly-supplied key survives (an opt-in for the child's creds). - expect(env.DEEPSEEK_API_KEY).toBe('explicit') - // A normal ambient var is forwarded. - expect(env.PATH).toBe(process.env.PATH) + // The spec.env layer merges after the seam's scrub, so the child's own + // explicitly-forwarded key survives while ambient credentials do not. + const running = spawnSubprocess({ + argv: ['bash', '-c', 'echo "[${ACP_TEST_AMBIENT_SECRET_TOKEN:-absent}|$DEEPSEEK_API_KEY]"'], + cwd: process.cwd(), + stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + graceMs: 1000, + env: { DEEPSEEK_API_KEY: 'explicit' }, + }) + await running.done + expect(running.collected.stdout!.readFrom(0).text.trim()).toBe('[absent|explicit]') } finally { - delete process.env.DSH_ACP_TEST_SECRET_TOKEN + delete process.env.ACP_TEST_AMBIENT_SECRET_TOKEN } }) }) @@ -140,6 +146,7 @@ describe('cwd resolution', () => { try { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) // A command that would create the sentinel if the child were ever spawned. await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} }) const parent = { id: 'parent', session: { header: {} } } as unknown as Agent @@ -158,6 +165,7 @@ describe('cwd resolution', () => { try { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, @@ -185,6 +193,7 @@ describe('cwd resolution', () => { const absolute = resolve(relative) const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, @@ -204,6 +213,7 @@ describe('cwd resolution', () => { // reintroduce the launch-directory fallback this resolution removed. const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', @@ -224,6 +234,7 @@ describe('cwd resolution', () => { try { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', @@ -242,6 +253,7 @@ describe('cwd resolution', () => { it('rejects a config cwd that is not an accessible directory at load', async () => { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', @@ -283,6 +295,7 @@ describe('cwd resolution', () => { try { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} }) const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })) @@ -360,7 +373,7 @@ describe('dsh-subagent-acp', () => { await expect(startAcpRun( request('p', controller.signal), // `touch ` — runs only if the process is actually spawned. - { command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS }, + { command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess }, )).rejects.toThrow('aborted before the ACP child started') // The binary was never launched — no sentinel. expect(existsSync(sentinel)).toBe(false) @@ -385,6 +398,7 @@ describe('dsh-subagent-acp', () => { }, disposeEofGraceMs: 1000, disposeGraceMs: 100, + spawn: spawnSubprocess, })).rejects.toThrow('ACP child published without a session id') // Startup rejects only after its private child reaches quiescence. The // marker proves rollback closed stdin and allowed the child's EOF flush. @@ -412,6 +426,7 @@ describe('dsh-subagent-acp', () => { // small so the whole ladder finishes well within the 4000ms bound. disposeEofGraceMs: 150, disposeGraceMs: 150, + spawn: spawnSubprocess, } const run = await startAcpRun(request(), spec) // Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a @@ -459,6 +474,7 @@ describe('dsh-subagent-acp', () => { }, disposeEofGraceMs: 2000, disposeGraceMs: 50, + spawn: spawnSubprocess, } const run = await startAcpRun(request(), spec) // Wait until the child is fully booted with its prompt in flight (its ACP @@ -492,6 +508,7 @@ describe('dsh-subagent-acp', () => { // Tiny EOF grace so the ignored-EOF window elapses quickly. disposeEofGraceMs: 150, disposeGraceMs: 2000, + spawn: spawnSubprocess, } const run = await startAcpRun(request(), spec) await waitForFile(ready) @@ -587,7 +604,7 @@ describe('dsh-subagent-acp', () => { it('rejects a spawn failure after provider-owned cleanup', async () => { await expect(startAcpRun( request(), - { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS }, + { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: spawnSubprocess }, )).rejects.toThrow() }) @@ -601,6 +618,7 @@ describe('dsh-subagent-acp', () => { try { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: process.execPath, @@ -626,6 +644,7 @@ describe('dsh-subagent-acp', () => { for (const bad of [{ disposeEofGraceMs: 0 }, { disposeGraceMs: -1 }, { disposeEofGraceMs: Number.NaN }]) { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad })) .rejects.toThrow(/subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number/) await ctx.fiber.dispose() @@ -635,6 +654,7 @@ describe('dsh-subagent-acp', () => { it('rejects a startup failure via the provider load path', async () => { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: '/nonexistent/acp-agent-binary', @@ -661,6 +681,7 @@ describe('dsh-subagent-acp', () => { env: { MOCK_CRASH_ON_PROMPT: '1' }, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, + spawn: spawnSubprocess, onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) }, }, ) @@ -699,6 +720,7 @@ describe('dsh-subagent-acp', () => { env: { MOCK_CRASH_ON_PROMPT: '1' }, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, + spawn: spawnSubprocess, onError: () => { throw new Error('sink boom') }, }, ) @@ -763,6 +785,7 @@ describe('dsh-subagent-acp', () => { it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) const fiber = await ctx.plugin(acp, { providerName: 'acp', command: 'x', args: [], permission: 'reject', env: {} }) expect(ctx.subagents.list()).toEqual(['acp']) await fiber.dispose() @@ -772,7 +795,7 @@ describe('dsh-subagent-acp', () => { it('has the namespace-plugin export shape (no stray default)', () => { expect('default' in acp).toBe(false) expect(acp.name).toBe('subagent-acp') - expect(acp.inject).toEqual(['subagents']) + expect(acp.inject).toEqual(['subagents', 'subprocess']) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(acp) as Record expect(unwrapped).toBe(acp) diff --git a/packages/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json index 175eb78e2f..2d60858d4a 100644 --- a/packages/subagent/subagent-acp/tsconfig.json +++ b/packages/subagent/subagent-acp/tsconfig.json @@ -27,7 +27,7 @@ "path": "../subagent" }, { - "path": "../subagent-subprocess" + "path": "../../subprocess/subprocess" }, { "path": "../../support/loader-smoke" diff --git a/packages/subagent/subagent-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md deleted file mode 100644 index bd1900d612..0000000000 --- a/packages/subagent/subagent-subprocess/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# @deepseek-ai/dsh-subagent-subprocess - -Shared machinery for **out-of-process subagent backends** — providers that spawn an external agent as a child process, such as the [ACP backend](../subagent-acp/README.md). A pure library (no provider, no registration, no Config): what every spawn-a-CLI-child backend needs to keep the parent deployment's credentials out of the child, tear the child down to quiescence, and isolate it from the host user's on-disk CLI state. Design rationale: [the Claude Code / Codex subagent backends Agent Note](../../../.agents/notes/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md). - -Every tunable is a **parameter**: the dispose ladder takes its grace periods per call, the config-dir helper takes an optional pinned path. Defaults live in each consuming plugin's Config (defaulted, validated fields changeable from `cordis.yml`), never in this library. - -## What it exports - -### `buildChildEnv(extra)` - -The credential env scrub (same pattern as the [bash executor](../../bash/bash-local/README.md)): the child env is the ambient env minus credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `extra` layered on top AFTER the scrub. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive, so the child CLI runs normally; the parent's own secrets never leak implicitly, while an explicitly supplied credential (the child's OWN key in a backend's `env` config) still reaches the child. - -### `spawnFailure(child)` - -Spawn-failure capture: a promise that resolves (never rejects) with the child's first `error` event. A spawn failure such as `ENOENT` is an event, not a thrown exception — without a listener Node crashes the parent process — so call this in the same tick as `spawn()` and race it in the run's result path; a bad command then settles as an ordinary child-level failure. For a child that spawns cleanly the promise never settles. - -### `disposeChildProcess(child, graces)` - -The platform-aware dispose ladder resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)): - -1. stdin EOF (when stdin is piped), then wait `graces.disposeEofGraceMs` — a cooperative child quiesces on its own, its flushes and nested-subprocess teardown intact; -2. on POSIX, `SIGTERM`, then wait `graces.disposeGraceMs`; -3. force termination — `SIGKILL` on POSIX and Node's `TerminateProcess` mapping on Windows — then wait at most `graces.disposeGraceMs` for exit; a signal error or missing exit rejects disposal. - -The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields. POSIX uses `disposeGraceMs` after both the graceful and forced signals; Windows skips the redundant graceful signal but uses it to bound forced-exit confirmation. The EOF window is deliberately separate and usually wider, since cooperative teardown may await a signal-trapping grandchild plus a final flush. - -The exit waits are internal to this ladder. They clean up their timer and listener on either outcome, so escalation never accumulates listeners on the child. - -### `createIsolatedConfigDir(prefix, pinnedPath?)` - -A per-run isolated config directory for an external CLI child (the target of `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection), so child behavior is a function of deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state exists on the host. Returns an `IsolatedConfigDir` handle: `path` goes into the child env, `remove()` runs on dispose. - -- **Fresh (default)**: a private (0700) `mkdtemp` dir under the OS temp root; `remove()` deletes it best-effort (never rejects — a leftover temp dir beats a failed dispose) and is idempotent. -- **Pinned** (`pinnedPath` set): the path is returned as-is — never created, never removed. A deployment that pins a directory to share child state across runs owns that directory's lifecycle. - -## Testing - -`tests/subagent-subprocess.spec.ts`: the env scrub and config-dir helpers run against the real process env and real filesystem (the rm-failure path injects its rejection at the fs boundary — a real recursive-rm failure is not portably provokable, and root ignores permission bits); the exit waits and platform termination paths run against a scriptable fake child. The [ACP backend suite](../subagent-acp/README.md) exercises them against real subprocesses end to end. - -## Model Experience - -Indirectly, through process-based subagent backends, whose child composition is constrained by credential scrubbing and isolated config directories. - -#### KV Cache effect - -No direct invalidation; the named consumer owns any request-prefix changes. - -## Known Limitations and Deferred Work - -- **The credential scrub is name-based** — only variables matching `KEY` / `SECRET` / `TOKEN` are removed; differently named secrets such as `PASSWORD` pass through unless the backend supplies a stricter environment. -- **Signals target the direct child only** — teardown relies on a cooperative CLI to reap its descendants before exit; a re-parented or independently detached grandchild can outlive the ladder. -- **Fresh config-dir cleanup is best-effort** — an `rm` failure leaves private state under the OS temp root rather than failing disposal. -- **Pinned config directories are wholly operator-owned** — the helper neither creates, validates, locks, nor removes them, so concurrent runs may share and race on that state. diff --git a/packages/subagent/subagent-subprocess/package.json b/packages/subagent/subagent-subprocess/package.json deleted file mode 100644 index bd573b3c0c..0000000000 --- a/packages/subagent/subagent-subprocess/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-subagent-subprocess", - "description": "Shared out-of-process subagent machinery: credential env scrub, spawn-failure capture, child-exit waits, the EOF-to-SIGTERM-to-SIGKILL dispose ladder, and isolated config dirs (pure lib; registers nothing)", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts deleted file mode 100644 index 47a97bafb6..0000000000 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn an external - * agent as a child process and must keep the parent deployment's credentials out of it, tear - * it down to quiescence, and isolate it from the host user's on-disk CLI state. This package - * registers no provider; consuming plugins own and validate every timing or path default. - * @module @deepseek-ai/dsh-subagent-subprocess - */ - -import type { ChildProcess } from 'node:child_process' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -/** - * Credential-shaped ambient env vars are NOT forwarded to a child by default - * (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a - * spawned process implicitly). Same pattern as the bash executor. The child - * agent needs its OWN credentials to reach a model — those are supplied - * explicitly via the `extra` layer of {@link buildChildEnv}, which lands AFTER - * the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental - * `AWS_SECRET_ACCESS_KEY` does not. - */ -const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i - -/** - * The ambient env minus credential-shaped vars, plus the caller's explicit - * env. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive the scrub, so - * a child CLI runs normally; only credential-shaped names are dropped. - * @param extra - explicit vars layered on top AFTER the scrub, so a - * credential-shaped name supplied deliberately still reaches the child. - * @returns the environment to spawn the child with. - */ -export function buildChildEnv(extra: Record): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {} - for (const [key, value] of Object.entries(process.env)) { - if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value - } - return { ...env, ...extra } -} - -/** - * Capture the child's spawn-level `error` event as a promise. Call in the same tick as - * `spawn()`; otherwise an early event can be unhandled and crash the parent. - * @param child - the just-spawned child process. - * @returns a promise that RESOLVES (never rejects) with the child's first - * `error` event; for a child that spawns cleanly it never settles. - */ -export function spawnFailure(child: ChildProcess): Promise { - return new Promise((resolve) => { - child.once('error', (err) => { resolve(err) }) - }) -} - -/** - * Race the child's exit against a timer. Neither outcome leaves anything - * behind on the child: the exit listener is removed on timeout and the timer - * is cleared on exit, so repeated calls (the dispose ladder's tiers, a poll - * loop) never accumulate listeners. - * @param child - the child process to watch. - * @param ms - the wait window in milliseconds. - * @returns `true` if the child exits within `ms` (immediately if it is - * already gone), `false` on timeout. - */ -function exitsWithin(child: ChildProcess, ms: number): Promise { - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true) - return new Promise((resolve) => { - const onExit = (): void => { - clearTimeout(timer) - resolve(true) - } - // `.unref()` so a pending grace timer never keeps the parent's loop alive. - const timer = setTimeout(() => { - child.removeListener('exit', onExit) - resolve(false) - }, ms).unref() - child.once('exit', onExit) - }) -} - -/** - * The two grace periods of the dispose ladder, supplied per call by the - * consuming backend — each plugin carries them as defaulted, validated - * `disposeEofGraceMs`/`disposeGraceMs` Config fields, so teardown timing is - * deployment-tunable and this library hardcodes nothing. - */ -export interface DisposeLadderGraces { - /** - * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce - * ON ITS OWN — flush durable state, tear down its own nested subprocesses — - * before the parent escalates to platform termination. A separate (usually WIDER) - * grace than {@link DisposeLadderGraces.disposeGraceMs}: a cooperative - * child's EOF-driven teardown may itself be waiting on a signal-trapping - * grandchild plus a final flush, needing more than one signal-grace of - * headroom. - */ - disposeEofGraceMs: number - /** - * Termination confirmation window (ms): POSIX applies it after `SIGTERM` and again after - * `SIGKILL`; Windows applies it after the direct forced termination. - */ - disposeGraceMs: number -} - -/** Force-terminate a child and reject if no exit edge arrives within the configured grace. */ -function forceTerminateWithin(child: ChildProcess, ms: number): Promise { - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() - return new Promise((resolve, reject) => { - let accepted = false - let settled = false - const cleanup = (): void => { - clearTimeout(timer) - child.off('exit', onExit) - child.off('error', onError) - } - const settle = (complete: () => void): void => { - if (settled) return - settled = true - cleanup() - complete() - } - const onExit = (): void => { settle(resolve) } - const onError = (error: Error): void => { settle(() => { reject(error) }) } - child.once('exit', onExit) - child.once('error', onError) - const timer = setTimeout(() => { - const disposition = accepted ? 'accepted' : 'refused' - settle(() => { - reject(new Error(`child process did not exit within ${ms}ms after SIGKILL was ${disposition}`)) - }) - }, ms).unref() - try { - accepted = child.kill('SIGKILL') - if (child.exitCode !== null || child.signalCode !== null) settle(resolve) - } catch (error: unknown) { - settle(() => { reject(new Error('SIGKILL failed', { cause: error })) }) - } - }) -} - -/** - * Tear a child process down to quiescence, resolving only after exit: close stdin and allow - * cooperative flush, then use the host's graceful and forced termination semantics. POSIX - * sends `SIGTERM` before `SIGKILL`; Windows skips directly to forced termination because Node - * maps both signals to `TerminateProcess`. - * - * @param child - the child process to tear down. - * @param graces - the two grace periods, from the consuming plugin's Config. - * @param platform - the host platform, injectable for unit coverage. - * @throws When forced termination errors or the child does not report exit within - * `disposeGraceMs`. - */ -export async function disposeChildProcess( - child: ChildProcess, - graces: DisposeLadderGraces, - platform: NodeJS.Platform = process.platform, -): Promise { - // Already gone: nothing to reap. - if (child.exitCode !== null || child.signalCode !== null) return - // 1. Close stdin and allow cooperative teardown and durable-state flush. - child.stdin?.end() - if (await exitsWithin(child, graces.disposeEofGraceMs)) return - // 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate. - if (platform !== 'win32') { - child.kill('SIGTERM') - if (await exitsWithin(child, graces.disposeGraceMs)) return - } - // 3. Force-kill and await a bounded exit edge. - await forceTerminateWithin(child, graces.disposeGraceMs) -} - -/** - * A per-run config directory handle for an external CLI child — the target of - * `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection. Hand {@link path} to - * the child's environment; call {@link remove} on dispose. - */ -export interface IsolatedConfigDir { - /** The directory to point the child at. */ - path: string - /** - * Best-effort cleanup: removes the directory (recursively) iff this handle - * CREATED it — a pinned directory is never removed. Idempotent; never - * rejects (a leftover dir under the OS temp root is preferable to a failed - * dispose). - */ - remove(): Promise -} - -/** - * An isolated config dir for one child run, independent of host CLI state. Without - * `pinnedPath`, creates a private temp directory and removes it best-effort; a pinned directory - * is returned unchanged and remains deployment-owned. - * - * @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g. - * `dsh-subagent-codex-`); ignored when `pinnedPath` is set. - * @param pinnedPath - a deployment-pinned directory to use instead of a - * fresh one. - * @returns the directory handle: `path` for the child env, `remove()` for - * dispose. - */ -export async function createIsolatedConfigDir(prefix: string, pinnedPath?: string): Promise { - if (pinnedPath !== undefined) { - return { - path: pinnedPath, - remove(): Promise { - // A pinned dir is deployment-owned state (config the user asked to - // persist across runs); removing it here would destroy it. No-op. - return Promise.resolve() - }, - } - } - const path = await mkdtemp(join(tmpdir(), prefix)) - return { - path, - async remove(): Promise { - try { - await rm(path, { recursive: true, force: true }) - } catch { - // Best-effort by contract: swallows rm failures (EACCES/EBUSY-style — e.g. the dead - // child left an unreadable entry behind). - } - }, - } -} diff --git a/packages/subagent/subagent-subprocess/src/invariant.ts b/packages/subagent/subagent-subprocess/src/invariant.ts deleted file mode 100644 index 5e401cd738..0000000000 --- a/packages/subagent/subagent-subprocess/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-subagent-subprocess`. - * @module @deepseek-ai/dsh-subagent-subprocess/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-subprocess' - -/** Cordis companion plugin name. */ -export const name = 'subagent-subsubprocess-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts deleted file mode 100644 index d674937e92..0000000000 --- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { EventEmitter } from 'node:events' -import { existsSync } from 'node:fs' -import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { ChildProcess } from 'node:child_process' -import { - buildChildEnv, - createIsolatedConfigDir, - disposeChildProcess, - spawnFailure, -} from '../src/index.ts' - -// `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm -// failures are not portable and disappear under root, so this is the sanctioned filesystem seam. -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, rm: vi.fn(actual.rm) } -}) - -/** - * Unit tests for the shared out-of-process machinery. The env scrub and the - * isolated-config-dir helpers run against the REAL process env and REAL - * filesystem (one exception: the rm-failure path injects its rejection at the - * mocked fs boundary, see above); the exit waits and the dispose ladder run - * against a scriptable fake child so each escalation tier's timing is driven - * deterministically (the ACP backend's suite exercises the same ladder - * against real subprocesses end to end). - */ - -/** What fells a scripted {@link FakeChild}. */ -type LethalTrigger = 'eof' | NodeJS.Signals - -/** Per-scenario script for a {@link FakeChild}. */ -interface FakeChildScript { - /** - * The one trigger that makes the child exit (SIGKILL always does, - * uncatchable, like a real process). Omitted: only SIGKILL fells it. - */ - diesOn?: LethalTrigger - /** Delay (ms) between the lethal trigger and the exit event. */ - delayMs?: number - /** Complete the scripted exit inside the triggering call. */ - synchronousExit?: boolean - /** `false` models a child spawned without a stdin pipe. */ - stdin?: boolean -} - -/** - * A scriptable stand-in for a ChildProcess carrying exactly the surface the - * helpers read: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the - * `exit` event. - */ -class FakeChild extends EventEmitter { - exitCode: number | null = null - signalCode: NodeJS.Signals | null = null - readonly kills: NodeJS.Signals[] = [] - stdinEnded = false - readonly stdin: { end: () => void } | null - - constructor(private readonly script: FakeChildScript = {}) { - super() - this.stdin = script.stdin === false - ? null - : { end: () => { this.stdinEnded = true; this.maybeDie('eof') } } - } - - kill(signal: NodeJS.Signals): boolean { - this.kills.push(signal) - this.maybeDie(signal) - return true - } - - private maybeDie(trigger: LethalTrigger): void { - // SIGKILL is uncatchable — it always fells the child; any other trigger - // only when the scenario scripts it as the lethal one. - if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return - const exit = (): void => { - if (trigger === 'eof') this.exitCode = 0 - else this.signalCode = trigger - this.emit('exit', this.exitCode, this.signalCode) - } - if (this.script.synchronousExit === true) exit() - else setTimeout(exit, this.script.delayMs ?? 0) - } -} - -/** The helpers take a real ChildProcess; the fake carries the read surface. */ -function asChild(fake: FakeChild): ChildProcess { - return fake as unknown as ChildProcess -} - -describe('buildChildEnv', () => { - it('drops credential-shaped ambient vars (KEY/SECRET/TOKEN, case-insensitive)', () => { - process.env.DSH_PROC_TEST_API_KEY = 'leak' - process.env.dsh_proc_test_secret = 'leak' - process.env.DSH_PROC_TEST_TOKEN = 'leak' - try { - const env = buildChildEnv({}) - expect(env.DSH_PROC_TEST_API_KEY).toBeUndefined() - expect(env.dsh_proc_test_secret).toBeUndefined() - expect(env.DSH_PROC_TEST_TOKEN).toBeUndefined() - } finally { - delete process.env.DSH_PROC_TEST_API_KEY - delete process.env.dsh_proc_test_secret - delete process.env.DSH_PROC_TEST_TOKEN - } - }) - - it('forwards normal ambient vars', () => { - expect(buildChildEnv({}).PATH).toBe(process.env.PATH) - }) - - it('layers extras AFTER the scrub, so a deliberate credential-shaped name survives', () => { - process.env.DSH_PROC_TEST_EXTRA_TOKEN = 'ambient-leak' - try { - const env = buildChildEnv({ DSH_PROC_TEST_EXTRA_TOKEN: 'explicit' }) - // The ambient value was scrubbed; ONLY the explicit opt-in reaches the child. - expect(env.DSH_PROC_TEST_EXTRA_TOKEN).toBe('explicit') - } finally { - delete process.env.DSH_PROC_TEST_EXTRA_TOKEN - } - }) - - it('an extra overrides the ambient value of a non-credential var', () => { - process.env.DSH_PROC_TEST_PLAIN = 'ambient' - try { - expect(buildChildEnv({ DSH_PROC_TEST_PLAIN: 'override' }).DSH_PROC_TEST_PLAIN).toBe('override') - } finally { - delete process.env.DSH_PROC_TEST_PLAIN - } - }) -}) - -describe('spawnFailure', () => { - it('resolves (never rejects) with the first error event', async () => { - const fake = new FakeChild() - const failure = spawnFailure(asChild(fake)) - const err = new Error('spawn ENOENT') - fake.emit('error', err) - await expect(failure).resolves.toBe(err) - }) - - it('never settles for a child that spawns cleanly and exits', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM' }) - const failure = spawnFailure(asChild(fake)) - fake.kill('SIGTERM') - await new Promise(resolve => fake.once('exit', () => { resolve() })) - // A clean lifecycle emits `exit`, never `error` — the capture stays - // pending forever, so a race against it is decided by the other arms. - const settled = await Promise.race([ - failure.then(() => 'settled'), - new Promise(resolve => setTimeout(() => { resolve('pending') }, 30)), - ]) - expect(settled).toBe('pending') - }) -}) - -describe('disposeChildProcess', () => { - it('returns immediately for an already-exited child (no EOF, no signals)', async () => { - const fake = new FakeChild() - fake.exitCode = 0 - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) - expect(fake.stdinEnded).toBe(false) - expect(fake.kills).toEqual([]) - }) - - it('returns immediately for a child already dead by signal', async () => { - const fake = new FakeChild() - fake.signalCode = 'SIGKILL' - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) - expect(fake.stdinEnded).toBe(false) - expect(fake.kills).toEqual([]) - }) - - it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => { - const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) - expect(fake.stdinEnded).toBe(true) - expect(fake.kills).toEqual([]) - expect(fake.exitCode).toBe(0) - }) - - it('recognizes a child that exits synchronously on stdin EOF', async () => { - const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) - expect(fake.exitCode).toBe(0) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') - expect(fake.stdinEnded).toBe(true) - expect(fake.kills).toEqual(['SIGTERM']) - expect(fake.signalCode).toBe('SIGTERM') - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('recognizes a child that exits synchronously on SIGTERM', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') - expect(fake.kills).toEqual(['SIGTERM']) - expect(fake.signalCode).toBe('SIGTERM') - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => { - const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux') - expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL']) - // Quiescence, not a request: at resolution the child has ACTUALLY exited - // (the exit event landed, despite the scripted post-SIGKILL delay). - expect(fake.signalCode).toBe('SIGKILL') - }) - - it('recognizes a child already gone when the final exit wait begins', async () => { - const fake = new FakeChild({ synchronousExit: true }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux') - expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL']) - expect(fake.signalCode).toBe('SIGKILL') - }) - - it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => { - const fake = new FakeChild() - vi.spyOn(fake, 'kill').mockImplementation((signal) => { - fake.kills.push(signal) - queueMicrotask(() => { - if (marker === 'exitCode') fake.exitCode = 0 - else fake.signalCode = 'SIGTERM' - }) - return true - }) - - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux') - expect(fake.kills).toEqual(['SIGTERM']) - }) - - it('walks the ladder for a child spawned without a stdin pipe', async () => { - const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux') - expect(fake.kills).toEqual(['SIGTERM']) - }) - - it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) - await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32') - expect(fake.kills).toEqual(['SIGKILL']) - expect(fake.signalCode).toBe('SIGKILL') - }) - - it('propagates a forced-termination error without waiting for the grace', async () => { - const fake = new FakeChild() - const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' }) - vi.spyOn(fake, 'kill').mockImplementation((signal) => { - fake.kills.push(signal) - fake.emit('error', failure) - return false - }) - - await expect(disposeChildProcess( - asChild(fake), - { disposeEofGraceMs: 1, disposeGraceMs: 1000 }, - 'win32', - )).rejects.toBe(failure) - expect(fake.kills).toEqual(['SIGKILL']) - expect(fake.listenerCount('error')).toBe(0) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('wraps a synchronous forced-termination exception and removes its listeners', async () => { - const fake = new FakeChild() - const failure = new Error('invalid signal state') - vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure }) - - await expect(disposeChildProcess( - asChild(fake), - { disposeEofGraceMs: 1, disposeGraceMs: 1000 }, - 'win32', - )).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure }) - expect(fake.listenerCount('error')).toBe(0) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('bounds a refused forced termination that produces no error or exit', async () => { - const fake = new FakeChild() - vi.spyOn(fake, 'kill').mockImplementation((signal) => { - fake.kills.push(signal) - return false - }) - - await expect(disposeChildProcess( - asChild(fake), - { disposeEofGraceMs: 1, disposeGraceMs: 10 }, - 'win32', - )).rejects.toThrow('child process did not exit within 10ms after SIGKILL was refused') - expect(fake.listenerCount('error')).toBe(0) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('bounds an accepted forced termination that never reports exit', async () => { - const fake = new FakeChild() - vi.spyOn(fake, 'kill').mockImplementation((signal) => { - fake.kills.push(signal) - return true - }) - - await expect(disposeChildProcess( - asChild(fake), - { disposeEofGraceMs: 1, disposeGraceMs: 10 }, - 'win32', - )).rejects.toThrow('child process did not exit within 10ms after SIGKILL was accepted') - expect(fake.listenerCount('error')).toBe(0) - expect(fake.listenerCount('exit')).toBe(0) - }) -}) - -describe('createIsolatedConfigDir', () => { - it('creates a fresh private mkdtemp dir under the OS temp root', async () => { - const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') - try { - expect(dir.path.startsWith(join(tmpdir(), 'dsh-subagent-subprocess-test-'))).toBe(true) - const st = await stat(dir.path) - expect(st.isDirectory()).toBe(true) - // Windows reports synthetic POSIX mode bits; privacy comes from the - // inherited directory ACL rather than chmod-compatible mode bits. - if (process.platform !== 'win32') expect(st.mode & 0o777).toBe(0o700) - } finally { - await dir.remove() - } - }) - - it('creates a distinct dir per call (per-run isolation)', async () => { - const a = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') - const b = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') - try { - expect(a.path).not.toBe(b.path) - } finally { - await a.remove() - await b.remove() - } - }) - - it('remove() deletes a fresh dir recursively and is idempotent', async () => { - const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-test-') - await writeFile(join(dir.path, 'settings.json'), '{}') - await dir.remove() - expect(existsSync(dir.path)).toBe(false) - // Second remove: nothing left to delete, still resolves. - await expect(dir.remove()).resolves.toBeUndefined() - }) - - it('returns a pinned dir verbatim and NEVER removes it', async () => { - const pinned = await mkdtemp(join(tmpdir(), 'dsh-subagent-subprocess-pinned-')) - try { - const dir = await createIsolatedConfigDir('ignored-prefix-', pinned) - expect(dir.path).toBe(pinned) - await dir.remove() - // The deployment owns a pinned dir's lifecycle — remove() must not touch it. - expect(existsSync(pinned)).toBe(true) - } finally { - await rm(pinned, { recursive: true, force: true }) - } - }) - - it('does not create a missing pinned path (the deployment owns its lifecycle)', async () => { - const missing = join(tmpdir(), `dsh-subagent-subprocess-missing-${process.pid}`) - const dir = await createIsolatedConfigDir('ignored-prefix-', missing) - expect(dir.path).toBe(missing) - expect(existsSync(missing)).toBe(false) - await dir.remove() - expect(existsSync(missing)).toBe(false) - }) - - it('remove() is best-effort: an rm rejection resolves instead of rejecting', async () => { - const dir = await createIsolatedConfigDir('dsh-subagent-subprocess-locked-') - try { - // The swallow contract is error-kind agnostic; EACCES stands in for the - // family (EBUSY, a vanished mount, …) that best-effort must absorb. - vi.mocked(rm).mockRejectedValueOnce(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })) - await expect(dir.remove()).resolves.toBeUndefined() - // The injected rejection consumed the only rm call — nothing was deleted. - expect(existsSync(dir.path)).toBe(true) - } finally { - await rm(dir.path, { recursive: true, force: true }) - } - }) -}) diff --git a/packages/subagent/subagent-subprocess/tsconfig.json b/packages/subagent/subagent-subprocess/tsconfig.json deleted file mode 100644 index d970a00263..0000000000 --- a/packages/subagent/subagent-subprocess/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../support/invariants" - } - ] -} diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 53b295d54e..5256065beb 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -1,10 +1,10 @@ # subprocess/ — subprocess capability family -The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). +The shared home for spawning managed child-process trees: fully-specified spawn specs with Node-shaped per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with spill files), the one credential scrub every harness spawner uses, offset-based incremental reads, tree-scoped signalling with SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder. Command defaulting, shell semantics, deadlines, protocol framing, and presentation stay with consumers — the [bash executors](../bash/README.md), the [LSP host](../lsp/README.md), and the [ACP subagent backend](../subagent/README.md). See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). | Package | ctx key | Role | |---|---|---| -| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with offset-based readers, and the shared `DSH_*` managed-environment and `CollectedOutput` vocabulary | -| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process groups, tail-keep truncation with bounded private spill files, the credential scrub and `DSH_*` merge order, kill escalation, and kill-and-join disposal | +| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, kill/terminate/waitForExit/dispose), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary | +| [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process trees, per-disposition stream wiring, tail-keep truncation with bounded private spill files, the `DSH_*` merge order, tree signalling with escalation, the dispose ladder, and terminate-and-join disposal | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 4bc2a71691..eccd842396 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -1,14 +1,14 @@ # @deepseek-ai/dsh-subprocess-local -Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today). +Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process tree, wires the spec's per-stream stdio dispositions (raw pipes, inherit, bounded tail-keep collection with optional spill files), and signals tree-scoped with SIGTERM→SIGKILL escalation. It has no config: every disposition, limit, and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seams' configs ([`dsh-bash-local`](../../bash/bash-local/README.md), [`dsh-lsp-local`](../../lsp/lsp-local/README.md), [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)). ## Behavior (and where it came from) -- **Detached process groups with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent). After the leader exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the spawn open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. -- **Tail-keep truncation + bounded spill files** — output beyond a stream's cap keeps the in-memory TAIL (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file whose path is reported when available. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. +- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. +- **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). -- **Offset-based reads** — `SubprocessHandle` readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist. -- **Kill-and-join disposal** — the service retains live handles only so its own disposal can kill every running group and await its exit; settled and spawn-failed handles leave the live set on settlement. +- **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. +- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement. ## Model Experience diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index f939b9e774..99c527ace6 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -258,8 +258,9 @@ export function killGroup(pid: number, sig: NodeJS.Signals): void { */ export function taskkillProcessTree(pid: number): void { if (pid <= 0) return - // Outcome deliberately unchecked: an already-absent tree (status 128) and - // exit races are as tolerable here as ESRCH is for a POSIX group signal. + // Outcome deliberately unchecked: an already-absent tree (status 128), exit + // races, and a missing taskkill binary (spawnSync reports, never throws) are + // as tolerable here as ESRCH is for a POSIX group signal. spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) } @@ -284,11 +285,14 @@ function signalTree( try { process.kill(-pid, sig) } catch { + /* v8 ignore start -- the fallback needs a live child whose group signal fails + (EPERM-style), which POSIX CI cannot stage; the swallow keeps teardown idempotent. */ try { child.kill(sig) } catch { // The direct child already exited; teardown remains idempotent. } + /* v8 ignore stop */ } } @@ -360,6 +364,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter if (settled) return signalTree(platform, pid, 'SIGTERM', child, taskkill) graceTimer = setTimeout(() => { + /* v8 ignore next -- the timer is cleared at settlement; only an in-flight fire racing the close event sees settled=true. */ if (!settled) signalTree(platform, pid, 'SIGKILL', child, taskkill) }, spec.graceMs) } @@ -422,6 +427,8 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter return true } catch (error) { const code = (error as NodeJS.ErrnoException).code + /* v8 ignore next -- POSIX reports an absent group as ESRCH; child-reaping timing + makes observing the other arm platform-dependent. */ if (code === 'ESRCH') return false /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */ @@ -442,7 +449,8 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter /** Race settlement against a timer without leaving listeners or live timers behind. */ const settlesWithin = async (ms: number): Promise => { if (settled) return true - let timer: NodeJS.Timeout | undefined + // The executor runs synchronously, so the timer is assigned before the race. + let timer!: NodeJS.Timeout const timeout = new Promise((resolve) => { // `.unref()` so a pending grace timer never keeps the parent's loop alive. timer = setTimeout(() => { resolve(false) }, ms) @@ -451,7 +459,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter try { return await Promise.race([done.then(() => true, () => true), timeout]) } finally { - if (timer !== undefined) clearTimeout(timer) + clearTimeout(timer) } } @@ -474,9 +482,11 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter return { pid, + /* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */ stdin: stdinMode === 'pipe' ? child.stdin ?? undefined : undefined, stdout: outMode === 'pipe' ? child.stdout ?? undefined : undefined, stderr: errMode === 'pipe' ? child.stderr ?? undefined : undefined, + /* v8 ignore stop */ collected: { ...stdoutCollector !== undefined ? { stdout: stdoutCollector } : {}, ...stderrCollector !== undefined ? { stderr: stderrCollector } : {}, diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 2f48b0fef3..ecff00d229 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import type { DshEnvironment } from '@deepseek-ai/dsh-subprocess' -import { killGroup, OutputCollector, spawnSubprocess } from '../src/spawn.ts' +import { killGroup, OutputCollector, spawnSubprocess, taskkillProcessTree } from '../src/spawn.ts' import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ @@ -575,6 +575,147 @@ describe('waitForExit', () => { }) }) +describe('coverage seams', () => { + it('taskkillProcessTree ignores non-positive pids and contains a missing binary', () => { + expect(() => { taskkillProcessTree(-1) }).not.toThrow() + expect(() => { taskkillProcessTree(0) }).not.toThrow() + // On POSIX there is no taskkill; spawnSync reports the failure in its + // result and the function stays silent — the same containment Windows + // relies on for an already-absent tree. + expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow() + }) + + it('dispose on a spawn-failed handle observes the rejection and returns', async () => { + const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' })) + const disposal = running.dispose({ eofGraceMs: 1_000, graceMs: 1_000 }) + await expect(running.done).rejects.toThrow() + await expect(disposal).resolves.toBeUndefined() + }) + + it("an 'inherit' stdout with collected stderr wires only the requested collector", async () => { + const running = spawnSubprocess({ + ...spec('echo to-parent; echo err >&2'), + stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 1000 } }, + }) + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(running.stdout).toBeUndefined() + expect(running.collected.stdout).toBeUndefined() + expect(running.collected.stderr!.readFrom(0).text).toBe('err\n') + }) + + it('terminate() after settlement is a no-op', async () => { + const running = spawnSubprocess(spec('true')) + await running.done + const spy = vi.spyOn(process, 'kill') + try { + running.terminate() + expect(spy).not.toHaveBeenCalled() + } finally { + spy.mockRestore() + } + }) + + it('waitForExit on a failed spawn reports exited immediately', async () => { + const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-spawn-test' })) + await expect(running.done).rejects.toThrow() + await expect(running.waitForExit()).resolves.toBe(true) + }) + + it('dispose() on an already-settled handle returns without signalling', async () => { + const running = spawnSubprocess(spec('true')) + await running.done + const spy = vi.spyOn(process, 'kill') + try { + await running.dispose({ eofGraceMs: 50, graceMs: 50 }) + expect(spy).not.toHaveBeenCalled() + } finally { + spy.mockRestore() + } + }) + + it('a batch-stdin handle exposes no stdin and dispose skips the EOF tier', async () => { + const running = spawnSubprocess(spec('cat', { stdin: 'batch\n' })) + expect(running.stdin).toBeUndefined() + await running.done + await running.dispose({ eofGraceMs: 50, graceMs: 50 }) + expect(running.collected.stdout!.readFrom(0).text).toBe('batch\n') + }) +}) + +describe('coverage seams 2', () => { + it('win32 treeAlive reports alive for a live child and gone after taskkill', async () => { + let killedPid = 0 + const running = spawnSubprocess(spec('sleep 60'), { + spillDir, + platform: 'win32', + taskkill: (pid) => { + killedPid = pid + try { + process.kill(pid, 'SIGKILL') + } catch { + // Already gone. + } + }, + }) + const aborted = new AbortController() + aborted.abort() + await expect(running.waitForExit(aborted.signal)).resolves.toBe(false) // alive branch + running.terminate() + await running.done + expect(killedPid).toBe(running.pid) + await expect(running.waitForExit()).resolves.toBe(true) + }) + + it('the win32 dispose ladder skips the POSIX SIGTERM tier and force-terminates', async () => { + const kills: number[] = [] + const running = spawnSubprocess({ + ...spec('sleep 60'), + stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + }, { + spillDir, + platform: 'win32', + taskkill: (pid) => { + kills.push(pid) + try { + process.kill(pid, 'SIGKILL') + } catch { + // Already gone. + } + }, + }) + await running.dispose({ eofGraceMs: 50, graceMs: 5_000 }) + // Exactly one forced tree termination: no POSIX SIGTERM tier ran. + expect(kills).toEqual([running.pid]) + }) + + it('dispose throws when even SIGKILL produces no exit within the grace', async () => { + // An inert taskkill simulates a tree that never reports exit. + const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} }) + await expect(running.dispose({ eofGraceMs: 20, graceMs: 40 })) + .rejects.toThrow(/did not exit within 40ms after forced termination/) + // Real cleanup: the injected platform spawned without detachment, so the + // child is a plain (group-less) POSIX process — kill it directly. + process.kill(running.pid, 'SIGKILL') + await running.done + }) + + it("stderr: 'pipe' exposes the raw stream", async () => { + const running = spawnSubprocess({ + ...spec('echo err >&2'), + stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: 'pipe' }, + }) + expect(running.stderr).toBeDefined() + const text = new Promise((resolve) => { + let out = '' + running.stderr!.on('data', (chunk: Buffer) => { out += chunk.toString('utf8') }) + running.stderr!.on('end', () => { resolve(out) }) + }) + await running.done + expect(await text).toBe('err\n') + }) +}) + describe('argv validation', () => { it('rejects an empty argv before spawning', () => { expect(() => spawnSubprocess({ ...spec('true'), argv: [] })).toThrow(/non-empty program name/) diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 15165a730d..5c45253154 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -4,13 +4,14 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes ## Contract -- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close and rejects only for spawn-level failures. -- The spec is fully explicit — argv, cwd, per-stream byte caps, spill cap, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted here; a consumer that wants a shell passes `['bash', '-c', command]` itself. -- Output readers take whole-stream byte offsets and never consume: independent readers cannot steal one another's deltas. A read whose offset slid out of the in-memory tail is `lossy` and points at the full-stream spill file when one exists. -- `kill()` and the spec's abort signal escalate SIGTERM→grace→SIGKILL across the whole detached group; the service reacts to the abort but never classifies why (callers own deadlines and cause classification). -- Disposal kills all still-running managed processes and awaits their exit. +- `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures. +- The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. +- Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. +- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification). +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the function. +- Disposal of the service terminates all still-running managed processes and awaits their exit. -See the [process data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). +See the [subprocess data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). ## Model Experience @@ -22,5 +23,5 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work -- **One consumer family so far** — the seam's shape is proven against the bash executors only; the other in-repo spawn sites (LSP servers, PTY backends, subagent transports) keep their own bespoke process handling until their stream/lifecycle needs are re-examined against this contract. -- **POSIX group semantics are assumed** — the handle vocabulary (`pid` as group leader, group kills, SIGTERM/SIGKILL escalation) has no Windows story. +- **node-pty and SDK-managed spawns share only the scrub** — the PTY backend's terminal fork and the MCP SDK's own stdio transport cannot route their spawns through this seam (the library owns the fork/spawn call); they import `scrubbedParentEnv` so the environment policy stays single-sourced. +- **The dispose ladder assumes stdin-EOF cooperation** — a child that quiesces on a different signal (SIGHUP conventions, control sockets) needs its own tier-1 before the generic ladder fits. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82f3691044..d85d124931 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2526,6 +2526,12 @@ importers: '@deepseek-ai/dsh-lsp': specifier: workspace:^ version: link:../lsp + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout @@ -2597,6 +2603,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -2691,6 +2700,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -2858,6 +2870,9 @@ importers: '@deepseek-ai/dsh-session-persistence-sqlite': specifier: workspace:^ version: link:../../session-persistence/session-persistence-sqlite + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../../subagent/tool-subagent @@ -3492,9 +3507,12 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent - '@deepseek-ai/dsh-subagent-subprocess': + '@deepseek-ai/dsh-subprocess': specifier: workspace:^ - version: link:../subagent-subprocess + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -3624,15 +3642,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/subagent/subagent-subprocess: - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/subagent/tool-subagent: dependencies: schemastery: @@ -4702,9 +4711,6 @@ importers: '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:^ version: link:../../packages/subagent/subagent-spawn - '@deepseek-ai/dsh-subagent-subprocess': - specifier: workspace:^ - version: link:../../packages/subagent/subagent-subprocess '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../packages/subprocess/subprocess diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index c2124da532..24e126a63d 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -65,7 +65,6 @@ "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", - "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 526e2ea5cd..58e63b5b3a 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -271,8 +271,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Subprocess seam', mode: 'seam', implementations: ['subprocess-local'], - consumers: ['bash-local', 'bash-sandbox'], - note: 'The bash executors spawn their process groups through ctx.subprocess; the service owns group lifetime, bounded spill-backed output, and kill escalation.', + consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp'], + note: 'The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.', }, { key: 'bash', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 8dfde7ad11..f6a3ada2f3 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2201,6 +2201,36 @@ "doc": "docs/core-data-structures/subprocess.md", "symbol": "CollectedOutput", "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessStdinMode", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessCollect", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessOutputMode", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessStdio", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessCollectedOutputs", + "source": "packages/subprocess/subprocess/src/types.ts" + }, + { + "doc": "docs/core-data-structures/subprocess.md", + "symbol": "SubprocessDisposeGraces", + "source": "packages/subprocess/subprocess/src/types.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 0db4417fa5..024a82e933 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -87,7 +87,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' }, 'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' }, 'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' }, - 'packages/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' }, 'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' }, 'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' }, 'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index a6397660a4..c769d3fcac 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -132,7 +132,6 @@ { "path": "./packages/subagent/subagent" }, { "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/subagent-inprocess" }, - { "path": "./packages/subagent/subagent-subprocess" }, { "path": "./packages/subagent/subagent-spawn" }, { "path": "./packages/subagent/subagent-fork" }, { "path": "./packages/subagent/subagent-acp" }, From 3c98ade986ef99aff210a8e7191ab43c054b35b2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:51:24 +0800 Subject: [PATCH 19/70] docs(subprocess): bilingual pair for the consumer-migration Agent Note Adds the zh counterpart, aligns the seam note's zh side with its updated interface bullet and follow-up sentence, records both pairs, and ratchets the translation-pairing manifest. --- ...26-subprocess-consumer-migration.i18n.yaml | 6 +++ ...-07-26-subprocess-consumer-migration.zh.md | 38 +++++++++++++++++++ .../2026-07-26-subprocess-seam.i18n.yaml | 4 +- .../2026-07-26-subprocess-seam.zh.md | 4 +- scripts/translation-pairing.manifest.json | 1 + 5 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml new file mode 100644 index 0000000000..4236aa02bb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml @@ -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 +2026-07-26-subprocess-consumer-migration.md: 805f27ba2e72a7f29d1b32053add95b8c33a62e2 +2026-07-26-subprocess-consumer-migration.zh.md: 039d32d9c737970153e91cfa288e4c9616813f7d diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md new file mode 100644 index 0000000000..00848c41af --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 进程 seam 转向 Node 形状,所有具备条件的 spawn 调用点一并迁入 + +Status: implemented + +[English](2026-07-26-subprocess-consumer-migration.md) | 中文 + +## 问题 + +[进程 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 则各自持有凭据清除的第三、第四、第五份副本——而这一切既不可替换,也无法集中测试。 + +## 决策 + +这道 seam 的词汇如今已是 Node 形状,凡能搭乘该服务的 spawn 调用点均已迁入: + +- **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(裸的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 +- **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。 +- **以进程树为范围的终止,按 Node 风格拆分**:`kill(signal?)` 只发送一个信号,结算后为空操作;`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(并承接 spec 的 abort 信号);`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界);`dispose(graces)` 是从 `subagent-subprocess` 吸收来的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯,按句柄 memoize 化。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。 +- **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 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 就是携带插件所配置宽限期的 `handle.dispose` 调用)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 + +挂载 lsp-local 或 subagent-acp 的组合如今都要加载 `dsh-subprocess-local`(这两个插件注入 `'subprocess'`);acp/lsp 测试 fixture(测试前置数据)补上了这一行组合配置。 + +## 曾考虑的替代方案 + +**保持只支持批量的 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 为何留在原地。 + +**迁移 test-support 启动器(acp-snapshot、loader-smoke)与 SDK package-manager 运行器。**否决:support 各包是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;而 SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;它改为共享凭据清除。 + +## 后果 + +换来的是:进程树信号发送、升级、dispose 阶梯、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 + +代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词从一个变为四个),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都带上 subprocess 这一行配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index 4d639a40f2..3cfcb0cc87 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml @@ -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 -2026-07-26-subprocess-seam.md: abfc43c1a960edd498b0960a191153008e39834c -2026-07-26-subprocess-seam.zh.md: 7fba975181b7871b34a8bc0e77d826e1ee2647f5 +2026-07-26-subprocess-seam.md: 5cf0e596603b4cd3240e5d9a114d95f413c6410b +2026-07-26-subprocess-seam.zh.md: 9cf38941953cf0348e6627e9bcb194cf231352d1 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md index 7fba975181..9cf3894195 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -12,7 +12,7 @@ Status: implemented 新的 `subprocess/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: -- **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、按流划分的上限、spill 上限、宽限期,一律不设默认值;随部署变化的旋钮依照 `dsh-bash` 的 request/spec 模板与无隐藏默认值规则,留在调用方 seam 的配置里)、携带基于偏移量的非消费式读取器的 `SubprocessHandle`、刻意不含超时/取消分类的 `SubprocessOutcome`,以及 `DSH_ENV_PREFIX`/`DshEnvironment`/`CollectedOutput` 类型。`argv` 绝不经过 shell 解释。 +- **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、逐流 stdio 处置方式、宽限期,一律不设默认值;随部署变化的旋钮依照 `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 文件的尾部保留截断、带双通道 `DSH_*` 合并的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 - **`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 所有。 @@ -25,7 +25,7 @@ Status: implemented **把进程管道留在 `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` 上。**作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 在其唯一真实的消费方家族上得到验证后交付;其余调用点已在 seam README 中列为暂缓工作。 +**在同一变更中把仓库其余 spawn 调用点(lsp-local、pty-local、subagent-subprocess、sdk package-manager、test-support 各启动器)迁到 `ctx.subprocess` 上。**在那个 PR 的规模下,作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 当时在其唯一真实的消费方家族上得到验证后交付。审阅随后恰恰要求以堆叠 PR 的形式完成这项后续工作;[消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md)记录了向 Node 形状的重塑,以及哪些调用点迁入(哪些因所有权归属而留守)。 **改把 `run_in_background`/任务语义放进进程 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。进程 seam 位于 bash 执行器*之下*,而不是与任务注册表并列。 diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index cbc39c0bde..d6392cce36 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -43,6 +43,7 @@ ".agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md", ".agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md", ".agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md", + ".agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md", ".agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md", ".agents/notes/implemented/feature/2026-06-14-acp-multi-session.md", ".agents/notes/implemented/feature/2026-06-15-code-mode.md", From c80fddf7a6dd90df522069632456405ac58d4940 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:16:43 +0800 Subject: [PATCH 20/70] docs(subprocess): final translation pass on the consumer-migration note --- .../2026-07-26-subprocess-consumer-migration.i18n.yaml | 2 +- .../2026-07-26-subprocess-consumer-migration.zh.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml index 4236aa02bb..6bc7a2cc01 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-26-subprocess-consumer-migration.md: 805f27ba2e72a7f29d1b32053add95b8c33a62e2 -2026-07-26-subprocess-consumer-migration.zh.md: 039d32d9c737970153e91cfa288e4c9616813f7d +2026-07-26-subprocess-consumer-migration.zh.md: 483d209b6bea09d9d1231f6bbeebf506cff07cb4 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md index 00848c41af..483d209b6b 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -29,10 +29,10 @@ Status: implemented **把 pty-local 与 mcp-client 的 spawn 也一并迁移。**基于所有权而非范围否决:node-pty 的 `fork()` 自行分配终端,MCP SDK 的 `StdioClientTransport` 在内部完成 spawn——这两处调用点都不归我们路由。它们采纳共享的凭据清除(那正是属于策略的部分),并在各自的 README 中说明 spawn 为何留在原地。 -**迁移 test-support 启动器(acp-snapshot、loader-smoke)与 SDK package-manager 运行器。**否决:support 各包是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;而 SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;它改为共享凭据清除。 +**迁移 test-support 启动器(acp-snapshot、loader-smoke)与 SDK package-manager 运行器。**否决:support 各包(package)是刻意保持轻依赖的测试基础设施,不得依赖产品 seam;而 SDK 向导那套附带重定向的 `stdio: 'inherit'` 语义,加上其完全脱离组合的生命周期(根本没有 cordis 上下文),使该服务并不合用;它改为共享凭据清除。 ## 后果 换来的是:进程树信号发送、升级、dispose 阶梯、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 -代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词从一个变为四个),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都带上 subprocess 这一行配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 +代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词从一个变为四个),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 From 79a28ad6d98988ab6e0eac7b71dd7cf2662998b5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:50:36 +0800 Subject: [PATCH 21/70] fix(subprocess): tree-scoped escalation and byte-exact tails (Codex round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (A1) terminate()/dispose()/service teardown keyed on direct-child settlement could leak a TERM-trapping descendant that outlived the leader (Codex reproduced it with a disowned trap-SIGTERM helper). kill()/terminate() now gate on tree liveness instead of outcome settlement; the SIGKILL escalation timer survives settle (unref'd, re-probing the tree); dispose's tier quiescence is whole-tree exit via a bounded waitForExit; the service's live set releases handles only when their tree is gone, and its teardown awaits tree exit. Three new suites pin the survivor scenarios end to end (terminate, dispose, service teardown). (A2) the escalation branch is now real tested behavior — its ignore is gone; the one remaining signalTree guard ignore states why it is unreachable through the handle verbs. (A3) docs contradictions fixed: the impl README's stale POSIX-only bullet now states the contained best-effort Windows tree story; the lsp-local README no longer claims taskkill failures stay visible (containment + the tree-liveness wait is the actual contract); the architecture tables (en+zh) list all three consumer families. (B1) OutputCollector keeps a byte-exact tail across uneven chunk boundaries (trim the head chunk instead of dropping it whole) — the LSP diagnostic-tail contract; pinned by a cross-chunk test. (B2) the subagent-acp coverage ignore is narrowed to exactly the never-settling success arm. --- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- packages/lsp/lsp-local/README.md | 2 +- packages/subagent/subagent-acp/src/run.ts | 12 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/package.json | 2 + .../subprocess/subprocess-local/src/index.ts | 18 ++- .../subprocess/subprocess-local/src/spawn.ts | 146 ++++++++++-------- .../subprocess-local/tests/spawn.spec.ts | 90 +++++++++-- .../subprocess/subprocess-local/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 12 files changed, 195 insertions(+), 91 deletions(-) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 5c9c706d6c..0c67fdd66e 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 -architecture.md: 9c4c9fc12a51c9c49d02a7aa9c3633ae7c95e4fe -architecture.zh.md: bc6adef969f60e7a7522a1877d29b2f90fc0c322 +architecture.md: 8334153482843f26defa8d175042a1349d6c9eca +architecture.zh.md: bb874654496bc9d131b1d7529c3585ed4682c0cf diff --git a/docs/architecture.md b/docs/architecture.md index 9c4c9fc12a..8334153482 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi | `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request/surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | -| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process groups under the bash executors | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process trees for the bash executors, the LSP host, and the ACP subagent backend | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement (argv wrapping, per-call policy) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index bc6adef969..bb87465449 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -28,7 +28,7 @@ | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力和会话表面压力 | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 | -| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | bash 执行器之下受管理的子进程组 | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | 供 bash 执行器、LSP host 与 ACP subagent 后端使用的受管子进程树 | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 同一执行环境内的进程限制(argv 包装、逐调用策略) | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 | diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 7c6c05b7df..3b7147f830 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -10,7 +10,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). - Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process. - Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. - Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. -- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`. Windows suppresses only taskkill's already-absent-tree result; command, permission, and other tree-kill failures remain visible. +- After protocol shutdown fails, terminates the server's descendant tree through the subprocess seam (POSIX process-group signaling; Windows `taskkill /T /F`). Tree-kill delivery is contained like every group signal — it races server exit — and quiescence is confirmed by the handle's tree-liveness wait rather than by the kill's own outcome. - Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. ## Configuration diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index aa9cd4f492..85f1b9fdde 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -180,10 +180,14 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe } /* v8 ignore stop */ // Spawn-level failure surfaces as `done` rejecting into the startup race; a - // clean exit must never win it, so the success arm parks forever. - /* v8 ignore start -- the success arm's never-settling executor is intentionally empty. */ - const spawnFailed: Promise = child.done.then(() => new Promise(() => {}), (err: unknown) => Promise.reject(toError(err))) - /* v8 ignore stop */ + // clean exit must never win it, so the success arm parks forever. (The ACP + // connection observing its streams closing bounds a child that exits + // without speaking the protocol.) + const spawnFailed: Promise = child.done.then( + /* v8 ignore next -- the success arm's never-settling executor is intentionally empty. */ + () => new Promise(() => {}), + (err: unknown) => Promise.reject(toError(err)), + ) spawnFailed.catch(() => { /* observed by the startup race; never unhandled */ }) // Startup rollback and the published handle share one process teardown. diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index eccd842396..c8aa39cc7b 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -20,7 +20,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work -- **POSIX-only** — detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported. +- **Windows tree support is best-effort and untested in CI** — termination routes through `taskkill /PID /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary; the suites cover the routing through an injected runner only, and `packages/subprocess/*` is excluded from the Windows test matrix. - **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. - **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 72ff50c422..871b4cfac6 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -29,11 +29,13 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index d76e5cf410..d71db5622a 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -28,13 +28,14 @@ export class LocalSubprocessService extends SubprocessService { constructor(ctx: Context) { super(ctx) ctx.effect(() => async () => { - // Terminate (escalating), then await closure so even a TERM-trapping - // child cannot outlive the fiber. + // Terminate (escalating), then await WHOLE-TREE exit — not just the + // direct child's settlement — so even a TERM-trapping descendant cannot + // outlive the fiber. const pending: Promise[] = [] for (const handle of this.live) { handle.terminate() // Spawn-failure rejections already settled and left the live set. - pending.push(handle.done.catch(() => {})) + pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit())) } this.live.clear() await Promise.all(pending) @@ -44,10 +45,13 @@ export class LocalSubprocessService extends SubprocessService { spawn(spec: SubprocessSpawnSpec): SubprocessHandle { const handle = spawnSubprocess(spec, this.internals) this.live.add(handle) - handle.done.then( - () => { this.live.delete(handle) }, - () => { this.live.delete(handle) }, - ) + // Release ownership only once the whole TREE is gone, not at direct-child + // settlement — a TERM-trapping helper that outlives the leader must stay + // owned so teardown can still escalate it. For the common no-survivor + // case waitForExit resolves immediately after settlement. + const release = (): Promise => + handle.waitForExit().then(() => { this.live.delete(handle) }) + handle.done.then(release, release) return handle } } diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 99c527ace6..212c4cb53e 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -14,7 +14,8 @@ import { randomBytes } from 'node:crypto' import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { setTimeout as sleepMs } from 'node:timers/promises' +import { deadline } from '@deepseek-ai/dsh-timeout' import { DSH_ENV_PREFIX, scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { CollectedOutput, @@ -62,6 +63,14 @@ export interface SpawnInternals { platform?: NodeJS.Platform } +/** Timeout code marking a dispose-ladder tier bound (vs an external abort). */ +const DISPOSE_TIER_TIMEOUT = 'SUBPROCESS_DISPOSE_TIER' + +/** Liveness-poll cadence for tree-exit waits; unref'd so an abandoned wait cannot hold the parent's loop open. */ +function sleepTick(): Promise { + return sleepMs(15, undefined, { ref: false }) +} + let spillCounter = 0 let defaultSpillDir: string | undefined @@ -118,19 +127,20 @@ export class OutputCollector { if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk) this.chunks.push(chunk) this.bytes += chunk.length - while (this.bytes > this.maxBytes && this.chunks.length > 1) { - // Drop whole chunks from the head; pipe chunks are small (≤64KiB), so - // the retained tail tracks the cap closely enough for a model-facing - // truncation boundary. (length > 1 was just checked — shift() returns.) - const head = this.chunks.shift() as Buffer - this.bytes -= head.length - this.dropped = true - } - if (this.bytes > this.maxBytes && this.chunks.length === 1) { - // A single chunk larger than the cap: keep its tail. - const only = this.chunks[0] as Buffer - this.chunks[0] = only.subarray(only.length - this.maxBytes) - this.bytes = this.maxBytes + while (this.bytes > this.maxBytes) { + const head = this.chunks[0] as Buffer + const excess = this.bytes - this.maxBytes + if (head.length <= excess) { + // Drop the whole head chunk (length ≥ 1 is guaranteed while over cap). + this.chunks.shift() + this.bytes -= head.length + } else { + // Trim the head so the retained window is byte-exact at the cap — a + // diagnostic tail (an LSP server's stderr) must hold the LAST + // maxBytes regardless of how the stream was chunked. + this.chunks[0] = head.subarray(excess) + this.bytes -= excess + } this.dropped = true } } @@ -281,6 +291,7 @@ function signalTree( taskkill(pid) return } + /* v8 ignore next -- kill/terminate gate on treeAlive(), which is false for pid -1; this guard protects direct callers only. */ if (pid <= 0) return try { process.kill(-pid, sig) @@ -352,21 +363,50 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // Failed spawns use pid -1 so signalling remains a no-op. const pid = child.pid ?? -1 + /** Whether the detached tree's root (or POSIX group) is still alive. */ + const treeAlive = (): boolean => { + if (pid <= 0) return false + if (platform === 'win32') { + // Windows has no group-liveness probe; the direct child's exit is the + // observable boundary (taskkill /T already took the tree with it). + return child.exitCode === null && child.signalCode === null + } + try { + process.kill(-pid, 0) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + /* v8 ignore next 2 -- POSIX reports an absent group as ESRCH; child-reaping timing + makes observing the other arm platform-dependent. */ + if (code === 'ESRCH') return false + /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs + tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */ + if (code === 'EPERM') return true + return child.exitCode === null && child.signalCode === null + /* v8 ignore stop */ + } + } + const kill = (sig: NodeJS.Signals = 'SIGTERM'): void => { - // After settlement the tree is gone and the pid may be reused; callers - // commonly kill() in a finally, so this must not re-signal. - if (settled) return + // Guard on TREE liveness, not outcome settlement: a TERM-trapping helper + // can outlive the settled direct child and must stay signalable, while a + // fully-dead tree (possible pid reuse) must not be re-signalled from a + // caller's finally block. + if (!treeAlive()) return signalTree(platform, pid, sig, child, taskkill) } const terminate = (): void => { if (graceTimer !== undefined) return // escalation already in flight - if (settled) return + if (!treeAlive()) return signalTree(platform, pid, 'SIGTERM', child, taskkill) + // The escalation must survive direct-child settlement — the leader dying + // does not mean the tree died — so the timer is unref'd rather than + // cleared at settle, and re-checks tree liveness before force-killing. graceTimer = setTimeout(() => { - /* v8 ignore next -- the timer is cleared at settlement; only an in-flight fire racing the close event sees settled=true. */ - if (!settled) signalTree(platform, pid, 'SIGKILL', child, taskkill) + if (treeAlive()) signalTree(platform, pid, 'SIGKILL', child, taskkill) }, spec.graceMs) + graceTimer.unref() } // The caller owns timeout classification; this layer only reacts to abort. @@ -408,75 +448,51 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter }) child.on('close', settle) function cleanup(): void { - if (graceTimer !== undefined) clearTimeout(graceTimer) + // graceTimer deliberately NOT cleared: the SIGKILL escalation must be + // able to reach tree survivors after the direct child settles. if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) spec.signal?.removeEventListener('abort', onAbort) } }) - /** Whether the detached tree's root (or POSIX group) is still alive. */ - const treeAlive = (): boolean => { - if (pid <= 0) return false - if (platform === 'win32') { - // Windows has no group-liveness probe; the direct child's exit is the - // observable boundary (taskkill /T already took the tree with it). - return child.exitCode === null && child.signalCode === null - } - try { - process.kill(-pid, 0) - return true - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - /* v8 ignore next -- POSIX reports an absent group as ESRCH; child-reaping timing - makes observing the other arm platform-dependent. */ - if (code === 'ESRCH') return false - /* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs - tree-lifecycle tests on POSIX hosts where absence reports ESRCH. */ - if (code === 'EPERM') return true - return child.exitCode === null && child.signalCode === null - /* v8 ignore stop */ - } - } - const waitForExit = async (signal?: AbortSignal): Promise => { while (treeAlive()) { if (signal?.aborted) return false - await yieldToEventLoop() + await sleepTick() } return true } - /** Race settlement against a timer without leaving listeners or live timers behind. */ - const settlesWithin = async (ms: number): Promise => { - if (settled) return true - // The executor runs synchronously, so the timer is assigned before the race. - let timer!: NodeJS.Timeout - const timeout = new Promise((resolve) => { - // `.unref()` so a pending grace timer never keeps the parent's loop alive. - timer = setTimeout(() => { resolve(false) }, ms) - timer.unref() - }) - try { - return await Promise.race([done.then(() => true, () => true), timeout]) - } finally { - clearTimeout(timer) - } + /** + * Wait, bounded, for whole-tree exit — the dispose ladder's quiescence test. + * Tree liveness, not direct-child settlement: a TERM-trapping helper that + * outlives the leader must hold the ladder on its tier until it exits. + */ + const treeExitsWithin = async (ms: number): Promise => { + using bound = deadline(undefined, ms, DISPOSE_TIER_TIMEOUT) + return await waitForExit(bound.signal) } let disposal: Promise | undefined const dispose = (graces: SubprocessDisposeGraces): Promise => (disposal ??= (async () => { + // A spawn failure has no process to tear down; observe the rejection so + // disposal in a finally block cannot surface it as unhandled. + if (pid <= 0) { + await done.catch(() => {}) + return + } // 1. Close a piped stdin and allow cooperative teardown and flush. if (stdinMode === 'pipe') child.stdin?.end() - if (await settlesWithin(graces.eofGraceMs)) return + if (await treeExitsWithin(graces.eofGraceMs)) return // 2. POSIX gets a catchable graceful signal; Windows taskkill force-terminates. if (platform !== 'win32') { kill('SIGTERM') - if (await settlesWithin(graces.graceMs)) return + if (await treeExitsWithin(graces.graceMs)) return } // 3. Force-kill the tree and await a bounded exit edge. kill('SIGKILL') - if (!(await settlesWithin(graces.graceMs))) { - throw new Error(`child process did not exit within ${graces.graceMs}ms after forced termination`) + if (!(await treeExitsWithin(graces.graceMs))) { + throw new Error(`child process tree did not exit within ${graces.graceMs}ms after forced termination`) } })()) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index ecff00d229..38d4a970bd 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -349,6 +349,19 @@ describe('OutputCollector', () => { expect(readFileSync(out.spillPath!, 'utf8')).toBe('0123456789abcdef') }) + it('retains a byte-exact tail across uneven chunk boundaries', () => { + // The old whole-chunk drop could under-retain; a diagnostic tail must be + // exactly the LAST maxBytes regardless of chunking. + const collector = new OutputCollector(10, undefined, 'exact-tail', spillDir) + collector.push(Buffer.from('aaaa')) + collector.push(Buffer.from('bbbbbb')) + collector.push(Buffer.from('cc')) + const out = collector.finalize() + expect(out.text).toBe('aabbbbbbcc') + expect(Buffer.byteLength(out.text)).toBe(10) + expect(out.truncated).toBe(true) + }) + it('readFrom returns increments and flags lossy reads', () => { const collector = new OutputCollector(10, 100, 'test', spillDir) collector.push(Buffer.from('aaaaa')) @@ -439,16 +452,18 @@ describe('killGroup', () => { expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() }) - it('handle.kill() after settlement signals nothing and starts no grace timer', async () => { - // Cleanup code commonly kills handles in a finally; after settlement the - // group is gone and the pid may be reused, so a late kill must be inert - // (no signal to a possibly-recycled pgid, no referenced timer delaying exit). + it('handle.kill() after the tree died delivers no termination signal', async () => { + // Cleanup code commonly kills handles in a finally; once the tree is gone + // the pid may be reused, so a late kill must deliver nothing (the + // liveness PROBE — signal 0 — is the only process.kill allowed). const running = spawnSubprocess(spec('true')) await running.done + await running.waitForExit() const spy = vi.spyOn(process, 'kill') try { running.kill() - expect(spy).not.toHaveBeenCalled() + const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0) + expect(delivered).toEqual([]) } finally { spy.mockRestore() } @@ -575,6 +590,59 @@ describe('waitForExit', () => { }) }) +describe('tree-survivor escalation (terminate/dispose reach helpers the leader left behind)', () => { + it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => { + // The leader spawns a TERM-trapping helper with all stdio detached from + // the collected pipes, then exits: the helper holds the GROUP alive while + // the direct child settles. The escalation must still reach it. + const pidFile = join(spillDir, `survivor-${Date.now()}.pid`) + const running = spawnSubprocess(spec( + `bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; wait_placeholder=; exit 0`, + { graceMs: 300 }, + )) + const helper = await waitForPidFile(pidFile) + await running.done // direct child settled; helper survives in the group + expect(() => process.kill(helper, 0)).not.toThrow() + + running.terminate() // SIGTERM (trapped) → grace → SIGKILL the group + await expect(running.waitForExit()).resolves.toBe(true) + await waitGone(helper) + }) + + it('dispose() holds each tier on whole-tree exit, not direct-child settlement', async () => { + const pidFile = join(spillDir, `survivor-dispose-${Date.now()}.pid`) + const running = spawnSubprocess(spec( + `bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`, + { graceMs: 200 }, + )) + const helper = await waitForPidFile(pidFile) + await running.done + expect(() => process.kill(helper, 0)).not.toThrow() + + await running.dispose({ eofGraceMs: 100, graceMs: 300 }) + // The ladder only returns once the WHOLE tree is gone. + expect(() => process.kill(helper, 0)).toThrow() + }) + + it('service teardown awaits tree survivors, not just handle settlement', async () => { + const { Context } = await import('cordis') + const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local') + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as InstanceType).internals = { spillDir } + const pidFile = join(spillDir, `survivor-svc-${Date.now()}.pid`) + const running = ctx.subprocess.spawn(spec( + `bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`, + { graceMs: 200 }, + )) + const helper = await waitForPidFile(pidFile) + await running.done + await fiber.dispose() + // Teardown itself waited for the survivor to die. + expect(() => process.kill(helper, 0)).toThrow() + }) +}) + describe('coverage seams', () => { it('taskkillProcessTree ignores non-positive pids and contains a missing binary', () => { expect(() => { taskkillProcessTree(-1) }).not.toThrow() @@ -604,13 +672,15 @@ describe('coverage seams', () => { expect(running.collected.stderr!.readFrom(0).text).toBe('err\n') }) - it('terminate() after settlement is a no-op', async () => { + it('terminate() after the tree died delivers no termination signal', async () => { const running = spawnSubprocess(spec('true')) await running.done + await running.waitForExit() const spy = vi.spyOn(process, 'kill') try { running.terminate() - expect(spy).not.toHaveBeenCalled() + const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0) + expect(delivered).toEqual([]) } finally { spy.mockRestore() } @@ -622,13 +692,15 @@ describe('coverage seams', () => { await expect(running.waitForExit()).resolves.toBe(true) }) - it('dispose() on an already-settled handle returns without signalling', async () => { + it('dispose() on an already-exited tree returns without delivering a signal', async () => { const running = spawnSubprocess(spec('true')) await running.done + await running.waitForExit() const spy = vi.spyOn(process, 'kill') try { await running.dispose({ eofGraceMs: 50, graceMs: 50 }) - expect(spy).not.toHaveBeenCalled() + const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0) + expect(delivered).toEqual([]) } finally { spy.mockRestore() } diff --git a/packages/subprocess/subprocess-local/tsconfig.json b/packages/subprocess/subprocess-local/tsconfig.json index 5a8dea211b..5272a4f78d 100644 --- a/packages/subprocess/subprocess-local/tsconfig.json +++ b/packages/subprocess/subprocess-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../subprocess" }, + { + "path": "../../util/timeout" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d85d124931..c3be946fc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3699,6 +3699,9 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../subprocess + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) From 95315386b398abb563af666a31f3a91c0aa50585 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 26 Jul 2026 17:55:54 +0800 Subject: [PATCH 22/70] refactor(telemetry): remove the OTel backend's flush forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review rounds each found a new silent-loss path in the same wrapper state (dispose racing an in-flight flush, overlapping hints displacing the retained promise, the provider's fixed 30s flush timeout rejecting while the processor still drains). Every path exists only because forwarding the seam's turn-boundary hint to forceFlush() made this backend the process's second flusher against undocumented SDK internals from the upstream experimental tree. The backend now implements no flush(): the batch processor is the only flusher, its scheduledDelayMillis (already deployment-tunable through the processor passthrough) governs export cadence, and shutdown()'s drain is complete by construction. The two race-pin tests collapse into one dispose-during-in-flight-batch drain pin; the seam's optional flush() contract now tells implementers they own the concurrent-flush/ shutdown interaction. Removal rationale and the reinstatement trigger (a stated turn-boundary latency requirement scheduledDelayMillis cannot meet — and then via the processor's own forceFlush(), never the provider's timeout-wrapped one) are recorded in the revival Agent Note, both languages. --- ...3-session-telemetry-otel-revival.i18n.yaml | 4 +- ...26-07-23-session-telemetry-otel-revival.md | 2 + ...07-23-session-telemetry-otel-revival.zh.md | 2 + docs/cordis-catalog/services.md | 2 +- .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session-telemetry-otel/README.md | 2 +- .../session-telemetry-otel/README.zh.md | 2 +- .../session-telemetry-otel/src/index.ts | 42 +++++------- .../session-telemetry-otel/tests/otel.spec.ts | 67 ++++++------------- .../session-telemetry/README.i18n.yaml | 4 +- .../telemetry/session-telemetry/README.md | 2 +- .../telemetry/session-telemetry/README.zh.md | 2 +- .../telemetry/session-telemetry/src/index.ts | 7 +- 13 files changed, 56 insertions(+), 86 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index e5c566662a..e17d42d2bb 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -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 -2026-07-23-session-telemetry-otel-revival.md: 28a88218566dcfe34a3b99b682a4c21d22e00ce2 -2026-07-23-session-telemetry-otel-revival.zh.md: 79428c5fbb76bdd8f3c2d0b856edd712b6a650d0 +2026-07-23-session-telemetry-otel-revival.md: 476073751a52989434798d4efa0013fc9908d6fa +2026-07-23-session-telemetry-otel-revival.zh.md: 78372554bc57ef298d399b7f01ba8a122964d5d5 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index 28a8821856..476073751a 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -28,6 +28,8 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry **Map onto OTel spans (GenAI semantic conventions) instead of logs.** Rejected for this revival: the branch implementation's log mapping is reviewed and shipped-shaped; the span model is lossy for forkable, interruptible sessions and belongs to a future consumer with real span queries to serve. +**Forwarding the seam's turn-boundary `flush()` hint to the OTel provider's `forceFlush()`.** Shipped in the first revival round, then removed after three review rounds each found a new silent-loss path in the same wrapper state: a dispose racing an in-flight flush (the SDK's concurrent-flush guard makes shutdown's internal drain skip), overlapping hints displacing the retained promise, and the provider's fixed 30-second flush timeout rejecting while the processor still drains. Every path exists only because the forwarding made this backend the process's second flusher against undocumented SDK internals from the upstream experimental tree; with no `flush()` implemented, the batch processor is the only flusher, its `scheduledDelayMillis` (already deployment-tunable through the `processor` passthrough) governs export cadence, and `shutdown()`'s drain is complete by construction. Reinstate only if a deployment states a turn-boundary latency requirement `scheduledDelayMillis` cannot meet — and then by calling the retained `BatchLogRecordProcessor`'s own `forceFlush()`, never the provider's timeout-wrapped one. + ## Consequences A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/redact` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index 79428c5fbb..78372554bc 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -28,6 +28,8 @@ Status: implemented **映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 +**将 seam 的轮次边界 `flush()` 提示转发到 OTel provider 的 `forceFlush()`。** 首轮复活曾交付此转发,其后移除:三轮评审在同一份包装层状态中各发现一条新的静默丢失路径——dispose 与进行中的 flush 之间的竞态(SDK 的并发 flush 防护会令 shutdown 的内部排空被跳过)、相互重叠的提示顶掉留存的 promise、以及 provider 固定的 30 秒 flush 超时在批处理器仍在排空时便 reject。这些路径存在的唯一原因,是该转发让这个后端成为进程内第二个执行 flush 的组件,面对的还是上游实验性(experimental)源码树中未见诸文档的 SDK 内部行为;不实现 `flush()` 时,批处理器就是唯一执行 flush 的组件,其 `scheduledDelayMillis`(已可由部署方经 `processor` passthrough 调优)决定导出节奏,`shutdown()` 的排空从构造上就是完整的。仅当某个部署提出 `scheduledDelayMillis` 无法满足的轮次边界延迟要求时才恢复此转发——且届时应调用留存的 `BatchLogRecordProcessor` 自身的 `forceFlush()`,绝不调用 provider 那个带超时包装的版本。 + ## Consequences 部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/redact` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a94cbc17c1..d255d1a70a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1717,7 +1717,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:129`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:134`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index 6dd8b6b5fc..89ba658a6f 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/README.i18n.yaml @@ -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 -README.md: 2e0c902e7b16726829319c852eab3f26a68847b3 -README.zh.md: 4917ee1fcff91f07d29bbcb1b6957b80d9e73197 +README.md: 28fffc5f43f960de1a44700aa70050d792b81d4f +README.zh.md: 821969fea09487d9d4450e981a0d791a66dad3a1 diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 2e0c902e7b..28fffc5f43 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -17,7 +17,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th processor: {} # optional; passed verbatim to BatchLogRecordProcessor ``` -`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load. Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. +`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load. Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. ## What leaves the machine diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 4917ee1fcf..821969fea0 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -17,7 +17,7 @@ processor: {} # optional; passed verbatim to BatchLogRecordProcessor ``` -`exporter.url` 是本包唯一自行校验的字段:必填、无默认值、必须能解析为 `http(s)`,因此缺失端点会在插件加载时失败。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。 +`exporter.url` 是本包唯一自行校验的字段:必填、无默认值、必须能解析为 `http(s)`,因此缺失端点会在插件加载时失败。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。 ## 哪些数据会离开本机 diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index d415d1a7e8..5a0d62cb10 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -147,36 +147,26 @@ export class TelemetryOtel extends Telemetry { }) } - /** Every not-yet-settled turn-boundary flush, retained so {@link shutdown} can order behind ALL of them. */ - private inflightFlush: Promise = Promise.resolve() - - /** Forward the turn-boundary hint to the SDK's flush, fire-and-forget. */ - override flush(): void { - // Best-effort hint: the SDK resolves forceFlush even when exports fail - // (failures go to its own diagnostics), and the coordinator stops calling - // this once the fiber is disposed — a rejection would be SDK drift. The - // settled promise is retained (not awaited): the SDK's concurrent-flush - // guard makes a flush that overlaps another return WITHOUT draining, so - // an overlapping hint resolves instantly and must JOIN the outstanding - // one, not displace it — shutdown orders behind the whole set. - /* v8 ignore next -- unreachable guard: forceFlush does not reject while the provider is alive */ - const flush = this.provider.forceFlush().catch(() => {}) - this.inflightFlush = Promise.all([this.inflightFlush, flush]).then(() => undefined) - } + // The seam's optional flush() hint is deliberately NOT implemented. The + // batch processor exports on its own cadence (`processor.scheduledDelayMillis`, + // the SDK's documented knob), and this backend is the SDK pipeline's only + // caller — forwarding the hint to `forceFlush()` was the sole source of + // concurrent flushes, whose undocumented interactions with shutdown's + // internal drain (concurrent-flush guard, provider-level flush timeout) + // silently dropped tail records. Removal history and the revival trigger: + // the revival Agent Note. /** - * Delegate disposal to the SDK's shutdown contract: flush the queue and - * quiesce. Orders behind every outstanding turn-boundary flush first — - * shutdown's internal flush is a no-op while one is in flight (the SDK's - * concurrent-flush guard), which would silently drop everything enqueued - * after that flush snapshot, including the coordinator's dispose-time - * `shutdown` markers. Awaited (and error-contained) by the coordinator's - * disposer. + * Delegate disposal to the SDK's shutdown contract: drain the queue and + * quiesce. With no concurrent `forceFlush()` in the process (see above), + * shutdown's internal drain is complete — everything emitted before this + * call, including the coordinator's dispose-time `shutdown` markers, is + * exported before the exporter closes. Awaited (and error-contained) by + * the coordinator's disposer. * @returns resolves when the SDK pipeline has quiesced. */ - async shutdown(): Promise { - await this.inflightFlush - await this.provider.shutdown() + shutdown(): Promise { + return this.provider.shutdown() } } diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index 85bfedeb1d..9069ec89da 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -123,12 +123,14 @@ describe('TelemetryOtel wire', () => { expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) }) - it('delivers records enqueued while a turn-boundary flush is in flight (flush/shutdown race)', async () => { - // Hold the collector's response to the flush-triggered export open until - // after disposal has begun: the SDK's concurrent-flush guard makes the - // shutdown-internal flush return early while another flush is running, so - // without ordering in the backend the coordinator's dispose-time shutdown - // marker (enqueued after the flush snapshot) would be dropped silently. + it('drains records enqueued after a timer export began: dispose during an in-flight batch', async () => { + // The backend implements NO flush() — the batch processor exports on its + // own cadence, and shutdown's internal drain is complete exactly because + // nothing in the process calls forceFlush() concurrently (the SDK's + // concurrent-flush guard skips draining otherwise). Pin that: hold the + // collector's response to the timer-triggered export open across + // disposal, and the dispose-time shutdown marker (enqueued after that + // batch's snapshot) must still arrive. const gate = Promise.withResolvers() const arrived = Promise.withResolvers() const { url, captures } = await mockCollector(async (index) => { @@ -137,10 +139,14 @@ describe('TelemetryOtel wire', () => { await gate.promise } }) - const { ctx, fiber } = await boot(url) - const session = ctx.sessions.create(SessionId('race'), { meta: {} }) + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(TelemetryOtel, { + exporter: { url }, + processor: { scheduledDelayMillis: 10 }, + }) + const session = ctx.sessions.create(SessionId('drain'), { meta: {} }) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - ctx.telemetry.flush!() await arrived.promise const disposal = fiber.dispose() @@ -149,40 +155,6 @@ describe('TelemetryOtel wire', () => { gate.resolve(true) await disposal - const records = allRecords(captures) - const ops = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops') - expect(ops).toHaveLength(1) - expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) - }) - - it('orders shutdown behind the OLDEST in-flight flush when hints overlap', async () => { - // The SDK's concurrent-flush guard resolves an overlapping forceFlush() - // immediately; if the backend RETAINS only the latest flush promise, two - // back-to-back turn flushes leave shutdown awaiting the instantly-resolved - // second one while the first still exports — reopening the same silent - // drop the single-flush race test pins. - const gate = Promise.withResolvers() - const arrived = Promise.withResolvers() - const { url, captures } = await mockCollector(async (index) => { - if (index === 0) { - arrived.resolve(true) - await gate.promise - } - }) - const { ctx, fiber } = await boot(url) - const session = ctx.sessions.create(SessionId('race2'), { meta: {} }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - ctx.telemetry.flush!() - await arrived.promise - // Second hint while the first export is held open: resolves immediately - // under the SDK's guard and must not displace the outstanding one. - ctx.telemetry.flush!() - - const disposal = fiber.dispose() - await new Promise(resolve => setTimeout(resolve, 50)) - gate.resolve(true) - await disposal - const ops = allRecords(captures).filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops') expect(ops).toHaveLength(1) expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) @@ -209,15 +181,14 @@ describe('TelemetryOtel wire', () => { expect(types).toContain('turn/start') }) - it('maps the warn severity and forwards the flush hint to the SDK', async () => { + it('maps the warn severity and leaves the seam flush hint unimplemented', async () => { const { url, captures } = await mockCollector() const { ctx, fiber } = await boot(url) const session = ctx.sessions.create(SessionId('warn'), { meta: {} }) session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' }) - // The turn-boundary hint: safe, non-blocking, and enough to push the batch out. - expect(() => { - ctx.telemetry.flush!() - }).not.toThrow() + // No flush(): the coordinator's optional-call forwarding no-ops, and the + // batch processor owns export cadence end to end (see the backend note). + expect('flush' in ctx.telemetry && ctx.telemetry.flush !== undefined).toBe(false) await fiber.dispose() const blocked = allRecords(captures).find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'prompt/blocked')) diff --git a/packages/telemetry/session-telemetry/README.i18n.yaml b/packages/telemetry/session-telemetry/README.i18n.yaml index 49f06c4826..dd0da745e8 100644 --- a/packages/telemetry/session-telemetry/README.i18n.yaml +++ b/packages/telemetry/session-telemetry/README.i18n.yaml @@ -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 -README.md: 73bc22053ad08725fecade43fb33fe944dfe3c60 -README.zh.md: 8e63e14bd8e099980133c9d6d864135fba7f39af +README.md: df0384d0528f0b3a95ded414444e1b11ea7d52bf +README.zh.md: d86fede206a4b2363f1deda86ff32d868d1db71d diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 73bc22053a..df0384d052 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -6,7 +6,7 @@ The telemetry seam: the CAPTURE side of session-event reporting, behind a backen ## The backend contract -`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path), optional `flush()` (a turn-boundary hint, fire-and-forget), and `shutdown()` (the lifecycle forward: flush-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` in its constructor. +`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` in its constructor. ## Capture points diff --git a/packages/telemetry/session-telemetry/README.zh.md b/packages/telemetry/session-telemetry/README.zh.md index 8e63e14bd8..d86fede206 100644 --- a/packages/telemetry/session-telemetry/README.zh.md +++ b/packages/telemetry/session-telemetry/README.zh.md @@ -6,7 +6,7 @@ ## 后端契约 -`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径上同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果)、以及 `shutdown()`(生命周期转发点:flush 并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端在其构造函数中组合 `TelemetryCoordinator`。 +`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径上同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端在其构造函数中组合 `TelemetryCoordinator`。 ## 捕获点 diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index 6d92a9547d..b52f80fbbe 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -103,7 +103,12 @@ export interface TelemetryBackend { * Optional hint that a natural boundary (turn end) passed — a backend may * forward it to its SDK's flush so records land at turn boundaries. Called * fire-and-forget; implementations must not block and must not throw - * meaningfully (the coordinator contains exceptions). + * meaningfully (the coordinator contains exceptions). Most backends should + * leave this unimplemented and let their SDK's own batching cadence govern + * export timing: a backend that does implement it owns the interaction + * between its concurrent flushes and {@link shutdown}'s drain (the OTel + * backend removed its implementation for exactly that hazard — see the + * revival Agent Note). */ flush?(): void /** From f2972e846df2503e7834b490f858da05f906f0e9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:34:57 +0800 Subject: [PATCH 23/70] fix(subprocess): keep awaited tree waits and the pending SIGKILL ref'd (Codex round 2) An awaited waitForExit()/dispose() must hold the event loop open until the tree really exits: with the liveness tick and the escalation timer unref'd, a parent with no other live handles could exit claiming quiescence and orphan the survivors it promised to reap. The escalation's pending SIGKILL is a commitment; it self-bounds at graceMs. Module graph picks up the subprocess-local -> timeout edge. --- docs/module-graph.md | 3 ++- .../subprocess/subprocess-local/src/spawn.ts | 16 +++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 435411beb0..e96f83f935 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -283,6 +283,7 @@ flowchart TD pkg_storage_sqlite --> pkg_storage pkg_subprocess_local --> pkg_invariants pkg_subprocess_local --> pkg_subprocess + pkg_subprocess_local --> pkg_timeout pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout @@ -852,7 +853,7 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | -| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 212c4cb53e..8716f50b0c 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -66,9 +66,14 @@ export interface SpawnInternals { /** Timeout code marking a dispose-ladder tier bound (vs an external abort). */ const DISPOSE_TIER_TIMEOUT = 'SUBPROCESS_DISPOSE_TIER' -/** Liveness-poll cadence for tree-exit waits; unref'd so an abandoned wait cannot hold the parent's loop open. */ +/** + * Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an + * awaited teardown must keep the event loop alive until the tree really + * exits, or the parent can exit while claiming quiescence and orphan the + * survivors it promised to reap. + */ function sleepTick(): Promise { - return sleepMs(15, undefined, { ref: false }) + return sleepMs(15) } let spillCounter = 0 @@ -401,12 +406,13 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter if (!treeAlive()) return signalTree(platform, pid, 'SIGTERM', child, taskkill) // The escalation must survive direct-child settlement — the leader dying - // does not mean the tree died — so the timer is unref'd rather than - // cleared at settle, and re-checks tree liveness before force-killing. + // does not mean the tree died — so settle does not clear this timer, and + // it re-probes tree liveness before force-killing. It stays ref'd: the + // pending SIGKILL is a commitment, and a parent exiting before it fires + // would orphan a trapped survivor. Self-bounds at graceMs. graceTimer = setTimeout(() => { if (treeAlive()) signalTree(platform, pid, 'SIGKILL', child, taskkill) }, spec.graceMs) - graceTimer.unref() } // The caller owns timeout classification; this layer only reacts to abort. From 05ef4ffa9b1c111290f20be97932f7fab9f416b2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:35:41 +0800 Subject: [PATCH 24/70] docs(subprocess): zh wording refinements on the two seam notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faithfulness pass from the translation review: 接入 over 搭乘, first-mention disposition annotation, 原始的 Readable/Writable, 留在原地, 评审 over 审阅; pairing records re-recorded. --- .../2026-07-26-subprocess-consumer-migration.i18n.yaml | 2 +- .../2026-07-26-subprocess-consumer-migration.zh.md | 8 ++++---- .../architecture/2026-07-26-subprocess-seam.i18n.yaml | 2 +- .../architecture/2026-07-26-subprocess-seam.zh.md | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml index 6bc7a2cc01..4326462cbf 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-26-subprocess-consumer-migration.md: 805f27ba2e72a7f29d1b32053add95b8c33a62e2 -2026-07-26-subprocess-consumer-migration.zh.md: 483d209b6bea09d9d1231f6bbeebf506cff07cb4 +2026-07-26-subprocess-consumer-migration.zh.md: 41bdf04bc03517cf9fe10a61a565e442ecee5520 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md index 483d209b6b..41bdf04bc0 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -10,20 +10,20 @@ Status: implemented ## 决策 -这道 seam 的词汇如今已是 Node 形状,凡能搭乘该服务的 spawn 调用点均已迁入: +这道 seam 的词汇如今已是 Node 形状,凡能接入该服务的 spawn 调用点均已迁入: -- **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(裸的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 +- **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(原始的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 - **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。 - **以进程树为范围的终止,按 Node 风格拆分**:`kill(signal?)` 只发送一个信号,结算后为空操作;`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(并承接 spec 的 abort 信号);`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界);`dispose(graces)` 是从 `subagent-subprocess` 吸收来的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯,按句柄 memoize 化。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。 - **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 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 就是携带插件所配置宽限期的 `handle.dispose` 调用)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 -挂载 lsp-local 或 subagent-acp 的组合如今都要加载 `dsh-subprocess-local`(这两个插件注入 `'subprocess'`);acp/lsp 测试 fixture(测试前置数据)补上了这一行组合配置。 +挂载 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 调用。 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index 3cfcb0cc87..7fd27e6770 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write 2026-07-26-subprocess-seam.md: 5cf0e596603b4cd3240e5d9a114d95f413c6410b -2026-07-26-subprocess-seam.zh.md: 9cf38941953cf0348e6627e9bcb194cf231352d1 +2026-07-26-subprocess-seam.zh.md: 31c37ae07f9456078673ccd005bd44c74ffa672e diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md index 9cf3894195..31c37ae07f 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -12,7 +12,7 @@ Status: implemented 新的 `subprocess/` 能力家族拥有「运行并管理一个进程」;bash 家族保留「运行一条 bash 命令」,并成为前者的消费方: -- **`@deepseek-ai/dsh-subprocess`(接口)**——拥有 `ctx.subprocess` 的抽象 `SubprocessService`(仅一个方法:`spawn(spec): SubprocessHandle`),以及共享词汇:完全显式的 `SubprocessSpawnSpec`(argv、cwd、逐流 stdio 处置方式、宽限期,一律不设默认值;随部署变化的旋钮依照 `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`(接口)**——拥有 `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 文件的尾部保留截断、带双通道 `DSH_*` 合并的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 - **`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 所有。 @@ -25,7 +25,7 @@ Status: implemented **把进程管道留在 `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 的规模下,作为带有真实设计风险的范围蔓延否决。这些调用点在流与生命周期上的需求存在实质差异:node-pty 所有权(pty)、长生命周期 stdio 上的 LSP 分帧加进程树终止回退(lsp)、以 stdin EOF 打头的 dispose 阶梯和完全不缓冲输出(subagent 传输层)。把它们强行纳入一个按有界批量输出塑形的句柄之下,要么会让这道 seam 膨胀,要么会让句柄与消费方错配。依照「接口围绕当前消费方塑形」的规则,该 seam 当时在其唯一真实的消费方家族上得到验证后交付。审阅随后恰恰要求以堆叠 PR 的形式完成这项后续工作;[消费方迁移 Agent Note](2026-07-26-subprocess-consumer-migration.md)记录了向 Node 形状的重塑,以及哪些调用点迁入(哪些因所有权归属而留守)。 +**在同一变更中把仓库其余 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 形状的重塑,以及哪些调用点迁入(哪些因所有权归属而留在原地)。 **改把 `run_in_background`/任务语义放进进程 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。进程 seam 位于 bash 执行器*之下*,而不是与任务注册表并列。 From d0b87e8c0fe5a515f907a73f71291e72442d2937 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:39:07 +0800 Subject: [PATCH 25/70] docs: cross-link landstrip evaluation gate from sandbox note's win32 phase The sandbox note's deferred-phases plan for the Windows chain now points at the proposed landstrip evaluation gate, so whoever picks up that rung finds the pending evaluation. The landstrip note itself stays proposed; this only tracks where the pending decision lives (a permitted keep-implemented-notes-current edit). Mirrored in the .zh.md and the pair re-recorded. --- .../notes/implemented/feature/2026-07-06-sandbox.i18n.yaml | 4 ++-- .agents/notes/implemented/feature/2026-07-06-sandbox.md | 2 +- .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 6437d7813d..fdb8e71d3b 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -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 -2026-07-06-sandbox.md: 723ef170188dc11da24e049a1e2838fb240d0a17 -2026-07-06-sandbox.zh.md: a8c7743bb3d499fb58f507ea2c202b44efe2311d +2026-07-06-sandbox.md: a9af53adfdabc6919113d8c6cb00c0b5f9e58c1f +2026-07-06-sandbox.zh.md: 6db1a914f1560e17296b7ea28f8c7367ecc58a81 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 723ef17018..a9af53adfd 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -128,7 +128,7 @@ Each phase gets its full design when picked up, validated against the code at th - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). -- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. +- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. Before implementing this rung, complete the [landstrip evaluation gate](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index a8c7743bb3..6db1a914f1 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -128,7 +128,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。在实现该梯级之前,先完成 [landstrip 评估门禁](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)。 ## 曾考虑的替代方案 From 3bd4a9afa64733cfb59b42de60eafe09f101c73b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:11:13 +0800 Subject: [PATCH 26/70] docs: raise the AGENTS.md ceiling to 1690 for the subprocess group row Master's layout block already sits exactly at the 1680-word ceiling; this PR's one new line (the subprocess/ package group) is seven words the doc genuinely needs, so the ceiling moves just far enough to admit it. --- scripts/doc-budgets.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 3d0ce17051..ec3f028f84 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1680, + "AGENTS.md": 1690, "docs/AGENTS.md": 1150, "docs/architecture.md": 1800, "docs/cordis-primer.md": 600, From a238c4b064217e641b0508cdbae4d8fd340f6910 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:00:38 +0800 Subject: [PATCH 27/70] docs(subprocess): bring the zh side of the subprocess pairs along after the master merge Master made bilingual pairing mandatory repo-wide; the subprocess family docs get their zh counterparts: new pairs for the core-data subprocess catalog and the three subprocess READMEs (switcher lines added on the en side), and minimal zh updates for the packages/bash/bash-local README deltas this PR made, with pairing records recorded. --- .../core-data-structures/subprocess.i18n.yaml | 6 + docs/core-data-structures/subprocess.md | 2 + docs/core-data-structures/subprocess.zh.md | 161 ++++++++++++++++++ packages/README.i18n.yaml | 4 +- packages/README.zh.md | 1 + packages/bash/README.i18n.yaml | 4 +- packages/bash/README.zh.md | 4 +- packages/bash/bash-local/README.i18n.yaml | 4 +- packages/bash/bash-local/README.zh.md | 21 ++- packages/subprocess/README.i18n.yaml | 6 + packages/subprocess/README.md | 2 + packages/subprocess/README.zh.md | 12 ++ .../subprocess-local/README.i18n.yaml | 6 + .../subprocess/subprocess-local/README.md | 2 + .../subprocess/subprocess-local/README.zh.md | 29 ++++ .../subprocess/subprocess/README.i18n.yaml | 6 + packages/subprocess/subprocess/README.md | 2 + packages/subprocess/subprocess/README.zh.md | 28 +++ 18 files changed, 281 insertions(+), 19 deletions(-) create mode 100644 docs/core-data-structures/subprocess.i18n.yaml create mode 100644 docs/core-data-structures/subprocess.zh.md create mode 100644 packages/subprocess/README.i18n.yaml create mode 100644 packages/subprocess/README.zh.md create mode 100644 packages/subprocess/subprocess-local/README.i18n.yaml create mode 100644 packages/subprocess/subprocess-local/README.zh.md create mode 100644 packages/subprocess/subprocess/README.i18n.yaml create mode 100644 packages/subprocess/subprocess/README.zh.md diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml new file mode 100644 index 0000000000..10be6b6d75 --- /dev/null +++ b/docs/core-data-structures/subprocess.i18n.yaml @@ -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 +subprocess.md: 2481afe324b0cbd2404186ae79670da81038f8f5 +subprocess.zh.md: 291233c456f73a1ea80de19290a6fc71a1f22725 diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index 6e7cea3990..2481afe324 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -1,5 +1,7 @@ # Subprocess +English | [中文](subprocess.zh.md) + The subprocess seam is split across interface ([dsh-subprocess](../../packages/subprocess/subprocess), `ctx.subprocess`) and implementation ([dsh-subprocess-local](../../packages/subprocess/subprocess-local)); its consumers are other capability seams — today the [bash executor family](bash.md), which passes `['bash', '-c', command]` argv and owns every default. This seam owns the managed `DSH_*` environment namespace and the `CollectedOutput` shape; [dsh-bash](../../packages/bash/bash) re-exports them so bash consumers keep one import root. Source: [`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts) diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md new file mode 100644 index 0000000000..291233c456 --- /dev/null +++ b/docs/core-data-structures/subprocess.zh.md @@ -0,0 +1,161 @@ +# 进程管理器 + +[English](subprocess.md) | 中文 + +进程管理器 seam 分为接口([dsh-subprocess](../../packages/subprocess/subprocess),`ctx.subprocess`)与实现([dsh-subprocess-local](../../packages/subprocess/subprocess-local));它的消费方是其他能力 seam:目前是 [bash 执行器家族](bash.md),后者传入 `['bash', '-c', command]` argv,并拥有每一项默认值。该 seam 拥有受管的 `DSH_*` 环境命名空间与 `CollectedOutput` 形状;[dsh-bash](../../packages/bash/bash) 将二者重导出,使 bash 消费方保持单一导入入口。 + +源码:[`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts) + +## 受管环境命名空间与捕获的输出 + +`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方快照之前丢弃环境中已有的 `DSH_*` 名称,每条被捕获的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 + +```ts type-equiv +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ +type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` +``` + +```ts type-equiv +/** Trusted DeepSeek Harness variables for one child-process execution. */ +type DshEnvironment = Readonly> +``` + +```ts type-equiv +/** One captured stream: the (possibly truncated) text plus recovery info. */ +interface CollectedOutput { + /** Collected text — the TAIL of the stream when truncated. */ + text: string + /** True when bytes were dropped from `text`. */ + truncated: boolean + /** Path to a file holding the COMPLETE stream, when truncated and available. */ + spillPath?: string +} +``` + +## 完全显式的 spawn spec + +该 seam 不应用任何默认值:每项限制与目录都在 spec 上显式给出,因此由调用方自己的配置决定它们,而不是由某个隐藏的进程管理器默认值决定。`argv` 绝不经过 shell 解释。 + +```ts type-equiv +/** + * A fully-specified spawn request. This seam applies no defaults: every limit + * and directory is explicit, so the caller's own config — not a hidden + * subprocess-service default — decides them (the `dsh-bash` request/spec split + * is the owning template). + */ +interface SubprocessSpawnSpec { + /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ + argv: readonly string[] + /** Working directory for the child. */ + cwd: string + /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */ + stdoutMaxBytes: number + /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ + stderrMaxBytes: number + /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ + maxSpillBytes: number + /** Grace period for kill escalation and for inherited pipes after process exit. */ + graceMs: number + /** + * Abort signal — kills the process group when it fires. The caller owns + * deadlines and cause classification; this seam only reacts to the abort. + */ + signal?: AbortSignal | undefined + /** + * Bytes to write to the child's stdin, then close it. Absent (or empty) + * leaves stdin closed/empty. + */ + stdin?: string | undefined + /** + * Ordinary environment entries merged after the implementation's credential + * scrub. `DSH_*` names are rejected and belong in {@link dshEnv}. + */ + env?: Record | 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 +} +``` + +## 句柄与基于偏移量的读取 + +spawn 会立即返回一个实时句柄。输出读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;bash 工具呈现的消费游标模型,是消费方在这些读取器之上自行持有的状态。 + +```ts type-equiv +/** + * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL + * escalation; buffered output remains readable after exit. + */ +interface SubprocessHandle { + /** Process id (group leader); -1 when the spawn itself failed. */ + readonly pid: number + /** Live stdout reader (also readable after exit). */ + readonly stdout: SubprocessOutputReader + /** Live stderr reader (also readable after exit). */ + readonly stderr: SubprocessOutputReader + /** Resolves when the process closes; rejects only for spawn-level failures. */ + readonly done: Promise + /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ + kill(): void +} +``` + +```ts type-equiv +/** + * 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 SubprocessOutputReader { + /** + * Read everything captured since `fromByte`. When that offset has slid out + * of the in-memory tail window the read is `lossy` — it returns the whole + * retained tail and the gap is only recoverable from the spill file. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). + * @returns the delta text, the next offset, the `lossy` flag, and the spill path when one exists. + */ + readFrom(fromByte: number): SubprocessOutputRead +} +``` + +```ts type-equiv +/** One incremental {@link SubprocessOutputReader.readFrom} read. */ +interface SubprocessOutputRead { + /** Stream text from the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the in-memory tail window. */ + lossy: boolean + /** Path to the full-stream spill file, when one was created and remains intact. */ + spillPath?: string +} +``` + +## 结果不携带原因分类 + +`done` 报告原始退出事实。服务会在中止时终止进程,但绝不判定原因:调用方读取归自己所有的 deadline 信号,以区分超时与取消(即 bash 执行器的 `timedOut`/`aborted` 拆分)。 + +```ts type-equiv +/** + * Raw outcome of one closed process. Deliberately carries NO timeout or + * cancellation classification: the service kills on abort but does not decide + * why — the caller reads the signal it owns to classify causes. + */ +interface SubprocessOutcome { + /** Exit code; null when the process died from a signal. */ + exitCode: number | null + /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ + signal: NodeJS.Signals | null + stdout: CollectedOutput + stderr: CollectedOutput +} +``` + +## 服务行为 + +抽象的 [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam 只定义 `spawn`;[`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) 是本地实现(detached 进程组、以 spill 文件兜底的尾部保留收集、凭据清除、先终止再等待退出的 dispose(资源释放))。seam 契约见 [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md),具体机制见 [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md)。 diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 9b2c44af22..6f22ec2128 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -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 -README.md: d7427c3f9892f56185cc1175245f14a6ccea0d25 -README.zh.md: 6894aa7333f6ba4bc5723871fb77c18b5fb518a1 +README.md: e56ff806d2fabeb9a921b09897e006ed58c2019a +README.zh.md: 6787385d7ff4d361878e17c2945cc1dbef9c99ad diff --git a/packages/README.zh.md b/packages/README.zh.md index 6894aa7333..6787385d7f 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -13,6 +13,7 @@ | [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | | [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 | | [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 | +| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程组实现 | 产品:稳定表面 | | [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 | | [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 | | [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:面向模型所写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 | diff --git a/packages/bash/README.i18n.yaml b/packages/bash/README.i18n.yaml index 6f9db27161..7c222fb6f5 100644 --- a/packages/bash/README.i18n.yaml +++ b/packages/bash/README.i18n.yaml @@ -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 -README.md: 08b36270800cdd79c82d6781bbfb2e12e2dc2060 -README.zh.md: a98506a6cdf41e5b298b40e1b8e1faf0c4c917d2 +README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b +README.zh.md: 57c28b45cf713aeaac725edb70d1fc24912c35db diff --git a/packages/bash/README.zh.md b/packages/bash/README.zh.md index a98506a6cd..57c28b45cf 100644 --- a/packages/bash/README.zh.md +++ b/packages/bash/README.zh.md @@ -6,8 +6,8 @@ | 包 | 职责 | ctx key | |---|---|---| -| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇) | `ctx.bash` | -| `bash-local/` | 本地子进程 `BashExecutor` 实现 | (注册 `ctx.bash`) | +| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇,受管环境/输出词汇则从 [`subprocess/`](../subprocess/README.md) seam 重导出) | `ctx.bash` | +| `bash-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 `BashExecutor` 实现(命令默认值补全、deadline、终端环境、后台读取合并) | (注册 `ctx.bash`) | | `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) | | `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | diff --git a/packages/bash/bash-local/README.i18n.yaml b/packages/bash/bash-local/README.i18n.yaml index c7e9587289..f94d04f90e 100644 --- a/packages/bash/bash-local/README.i18n.yaml +++ b/packages/bash/bash-local/README.i18n.yaml @@ -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 -README.md: 1668f33e8acf6d749d4d3753478c12d48a19ac3c -README.zh.md: 0e0a4ad41b532e39f6f2470aa981a08b6d6230c1 +README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706 +README.zh.md: aa6de87df48ee943ccdd2c6227ad977f596b5516 diff --git a/packages/bash/bash-local/README.zh.md b/packages/bash/bash-local/README.zh.md index 0e0a4ad41b..aa6de87df4 100644 --- a/packages/bash/bash-local/README.zh.md +++ b/packages/bash/bash-local/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -`@deepseek-ai/dsh-bash` 执行器 seam 的本地子进程实现:`LocalBashExecutor` 每次调用都会在独立进程组中 spawn `bash -c `,收集有界输出,并用限制大小的完整流 spill 文件保留超量内容,随后针对整个进程组从 SIGTERM 逐步升级为 SIGKILL。 +`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c ` 作为受管进程组 spawn,并拥有所有 bash 形态的职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。进程组机制(以 spill 文件兜底的有界输出、凭据清除、kill 升级、dispose(资源释放))归进程管理器服务所有。 -包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`;子进程管道细节保留在该实现包内部。 +包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。 ## 配置 @@ -24,11 +24,11 @@ 设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下: -- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/run.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。 -- **使用逐步升级终止整个进程组**:子进程使用 `detached` spawn(拥有独立进程组);终止时先向该组发送 SIGTERM,经过 `graceMs` 宽限期后再发送 SIGKILL(默认 3 秒,沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束)。主 shell 退出后,继承的 stdout/stderr 管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地阻止命令结束。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 -- **保留尾部的截断 + 有界 spill 文件**:输出超过 `maxOutputBytes` 后,内存中保留尾部(错误/结果通常聚集在末尾,沿用 pi/OpenCode 的理由),同时将完整流追加到临时文件,并在可用时报告该路径。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台任务仍使用 `maxOutputBytes`。某个流大于 `maxSpillBytes` 时,会丢弃已不完整的 spill,仅返回带截断标记的尾部。如果最终关闭 spill 时报告延迟写回失败,执行器同样不会公布路径,以免声称存在不完整的文件。 -- **适合模型的环境变量 + 凭证清理**:以 `process.env` 为基础,移除形似凭证的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中的 `DSH_*` 名称,再设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果。spec 的普通 `env` 在清理后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止遗留嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 -- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 使用全流字节偏移量进行增量读取;dispose 会终止每个运行中的进程并等待其退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 +- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。 +- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 +- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自行发出信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 +- **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带标记分节的增量,由一个消费游标驱动。仍在运行的进程归进程管理器服务所有,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 ## 模型体验 @@ -42,8 +42,7 @@ - **自身不受约束**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 - **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流程需要它们。 -- **仅支持 POSIX**:`bash` 二进制、独立进程组、进程组终止以及 SIGTERM→SIGKILL 升级都已硬编码;不支持 Windows。 -- **凭证清理依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 -- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 +- **仅支持 POSIX**:`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。 +- **后台 spawn 失败提示只交付一次**:进程管理器不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 -原始进程处理位于 `src/run.ts`;`src/index.ts` 负责服务接线。 +凭据清除启发式规则与 spill 保留的注意事项随 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 记录;这些机制归它所有。 diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml new file mode 100644 index 0000000000..64edcdc78b --- /dev/null +++ b/packages/subprocess/README.i18n.yaml @@ -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 +README.md: f91609dc6b6e27fc26e5ffb4b7fd68fc9f4de556 +README.zh.md: d4e2cd69d21772834e0e55db2b14e19ea7ce1832 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 53b295d54e..f91609dc6b 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -1,5 +1,7 @@ # subprocess/ — subprocess capability family +English | [中文](README.zh.md) + The shared home for spawning managed child-process groups: fully-specified spawn specs, bounded tail-keep output with spill files, credential-scrubbed environments, offset-based incremental reads, and SIGTERM→grace→SIGKILL group kills. Command defaulting, shell semantics, deadlines, and presentation stay with consumers — the [bash executor family](../bash/README.md) is the first and owning consumer. See the [subprocess seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). | Package | ctx key | Role | diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md new file mode 100644 index 0000000000..d4e2cd69d2 --- /dev/null +++ b/packages/subprocess/README.zh.md @@ -0,0 +1,12 @@ +# subprocess/:进程管理能力家族 + +[English](README.md) | 中文 + +spawn 受管子进程组的共用归属位置:完全显式的 spawn spec、附带 spill 文件的有界尾部保留输出、经凭据清除的环境、基于偏移量的增量读取,以及 SIGTERM→宽限期→SIGKILL 的进程组终止。命令默认值补全、shell 语义、deadline 与呈现留在消费方:[bash 执行器家族](../bash/README.md)是第一个消费方,也拥有上述各项。参见[进程管理器 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 + +| 包(package) | ctx 键 | 角色 | +|---|---|---| +| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式的 `SubprocessSpawnSpec`、携带基于偏移量读取器的 `SubprocessHandle`,以及共享的 `DSH_*` 受管环境与 `CollectedOutput` 词汇 | +| [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地实现:detached 进程组、附带有界私有 spill 文件的尾部保留截断、凭据清除与 `DSH_*` 合并次序、kill 升级,以及先终止再等待退出的 dispose(资源释放) | + +服务拥有跨消费方重载的进程存续期;消费方拥有一个进程的含义(一条 bash 命令、未来的非 shell 运行器)以及塑造它的每一项默认值。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml new file mode 100644 index 0000000000..4dab94d5b8 --- /dev/null +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -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 +README.md: a18772055d33369f6feec1b1a0751bb299303e04 +README.zh.md: bc829d48e846055853753522287967cc9fd42ca6 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 4bc2a71691..a18772055d 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-subprocess-local +English | [中文](README.zh.md) + Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam: `LocalSubprocessService` spawns each spec's argv as a detached process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. It has no config: every limit and directory arrives on the spawn spec, so the deployment-varying knobs stay with the calling seam's config ([`dsh-bash-local`](../../bash/bash-local/README.md) today). ## Behavior (and where it came from) diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md new file mode 100644 index 0000000000..bc829d48e8 --- /dev/null +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -0,0 +1,29 @@ +# @deepseek-ai/dsh-subprocess-local + +[English](README.md) | 中文 + +[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地实现:`LocalSubprocessService` 把每个 spec 的 argv 作为 detached 进程组 spawn,收集有界输出,并用限制大小的完整流 spill 文件保留超量内容,随后针对整个进程组从 SIGTERM 逐步升级为 SIGKILL。该实现没有任何配置:每项限制与目录都随 spawn spec 到达,因此随部署变化的旋钮留在调用方 seam 的配置里(目前是 [`dsh-bash-local`](../../bash/bash-local/README.md))。 + +## 行为(以及设计来源) + +- **带升级的 detached 进程组**:子进程使用 `detached` spawn(拥有独立进程组);终止时先向该组发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束)。组长进程退出后,继承的 stdout/stderr 管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地阻止这次 spawn 结束。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 +- **尾部保留截断 + 有界 spill 文件**:输出超过某条流的上限后,内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),同时将完整流追加到一个私有临时文件,并在可用时报告该路径。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。 +- **凭据清除 + 受管 `DSH_*` 合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的普通 `env` 在清除后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止陈旧的嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +- **基于偏移量的读取**:`SubprocessHandle` 的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存。 +- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,只为让自身的 dispose 能终止每个仍在运行的进程组并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 + +## 模型体验 + +通过消费方 seam 间接影响(目前是 `dsh-tool-bash` 背后的 bash 执行器家族);进程输出与生命周期面向模型的全部渲染归消费方所有。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由具名消费方负责。 + +## 已知限制与暂缓事项 + +- **仅支持 POSIX**:detached 进程组、进程组终止以及 SIGTERM→SIGKILL 升级都已硬编码;不支持 Windows。 +- **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 +- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 + +原始进程处理位于 `src/spawn.ts`;`src/index.ts` 负责服务接线。 diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml new file mode 100644 index 0000000000..adc9ed8cc4 --- /dev/null +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -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 +README.md: 126f0fd6863739563b8d3cb5b4b958cee47dfb3d +README.zh.md: fdd5035867316349a91d1700cbf3f521a2bac117 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 15165a730d..126f0fd686 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-subprocess +English | [中文](README.zh.md) + The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes one method — `spawn(spec): SubprocessHandle` — plus the vocabulary shared by every consumer: the fully-explicit `SubprocessSpawnSpec`, `SubprocessHandle` with its non-consuming offset-based output readers, `SubprocessOutcome`, `CollectedOutput`, and the managed `DSH_*` environment namespace (`DSH_ENV_PREFIX`, `DshEnvironment`). The local implementation lives in [`dsh-subprocess-local`](../subprocess-local/README.md). ## Contract diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md new file mode 100644 index 0000000000..fdd5035867 --- /dev/null +++ b/packages/subprocess/subprocess/README.zh.md @@ -0,0 +1,28 @@ +# @deepseek-ai/dsh-subprocess + +[English](README.md) | 中文 + +进程管理器 seam(`ctx.subprocess`)。抽象的 `SubprocessService` 只暴露一个方法:`spawn(spec): SubprocessHandle`,外加所有消费方共享的词汇:完全显式的 `SubprocessSpawnSpec`、携带基于偏移量的非消费式输出读取器的 `SubprocessHandle`、`SubprocessOutcome`、`CollectedOutput`,以及受管的 `DSH_*` 环境命名空间(`DSH_ENV_PREFIX`、`DshEnvironment`)。本地实现位于 [`dsh-subprocess-local`](../subprocess-local/README.md)。 + +## 契约 + +- `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时 resolve,仅在 spawn 层面失败时 reject。 +- spec 完全显式(argv、cwd、按流划分的字节上限、spill 上限、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 在这里绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 +- 输出读取器接受全流字节偏移量且从不消费:独立的读取器不会抢走彼此的增量。偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在完整流 spill 文件存在时指向它。 +- `kill()` 与 spec 的 abort 信号对整个 detached 进程组执行 SIGTERM→宽限期→SIGKILL 升级;服务响应中止但绝不判定原因(deadline 与原因分类归调用方所有)。 +- dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 + +参见[进程数据结构目录](../../../docs/core-data-structures/subprocess.md)与 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 + +## 模型体验 + +通过消费方 seam 间接影响(目前是 `dsh-tool-bash` 背后的 bash 执行器家族);进程输出与生命周期面向模型的全部渲染归消费方所有。 + +#### KV Cache 影响 + +不会直接失效;请求前缀变更由具名消费方负责。 + +## 已知限制与暂缓事项 + +- **目前只有一个消费方家族**:该 seam 的形状仅在 bash 执行器上得到验证;仓库内其他 spawn 调用点(LSP 服务器、PTY 后端、subagent 传输层)继续保留各自专属的进程处理,直到它们的流与生命周期需求对照本契约得到重新审视。 +- **假定 POSIX 进程组语义**:句柄词汇(作为组长的 `pid`、进程组终止、SIGTERM/SIGKILL 升级)没有 Windows 方案。 From c4647a860945481f8ce68bd7774b757f221930b4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:29:41 +0800 Subject: [PATCH 28/70] test: adopt execa for hand-rolled subprocess plumbing, parseArgs for llm-mock-server CLI, vi.waitFor for poll loops Implements the execa Agent Note's four sub-changes: - execa (root devDep + loader-smoke dep) replaces the hand-rolled spawn-collect-timeout choreography in loader-smoke, apps/cli and cli-demo/acp-demo built-bin e2e, lsp-local and code-runtime-worker built-lib e2e, the tui pty-harness outer collector, the jsonrpc keyless smoke, and crash-recovery's child spawn. Genuinely custom parts stay custom: cli-demo's interrupt-on-marker, jsonrpc's line-predicate protocol driving, crash-recovery's SIGKILL-at-failpoint. The two loader-smoke /* v8 ignore */ OS-error branches are gone. - llm-mock-server CLI tokenizes via node:util parseArgs; numeric coercion/bounds/cross-option constraints stay manual; pinned error-message tests updated to the parseArgs texts. - both loadRootEnv copies in apps/web/tests are deleted: the owning vitest configs (web unconditionally, snapshot in record mode) already load the repo-root .env before these files run. - the four poll loops (acp-snapshot harness waits + crash-recovery waitForFile) ride vi.waitFor with explicit {interval, timeout}. --- apps/cli/tests/built-bin.e2e.ts | 31 ++--- apps/web/tests/scaffold.ts | 15 +-- apps/web/tests/smoke-real.e2e.ts | 17 +-- .../jsonrpc-agent/tests/keyless-smoke.e2e.ts | 54 +++----- examples/tui-agent/tests/pty-harness.ts | 55 ++++---- package.json | 1 + .../tests/built-lib.e2e.ts | 15 ++- .../examples/acp-demo/tests/built-bin.e2e.ts | 35 +++-- .../examples/cli-demo/tests/built-bin.e2e.ts | 52 ++++---- packages/lsp/lsp-local/tests/built-lib.e2e.ts | 15 ++- .../tests/crash-recovery.e2e.ts | 44 +++--- packages/support/acp-snapshot/src/harness.ts | 41 +++--- packages/support/llm-mock-server/src/cli.ts | 107 +++++++-------- .../support/llm-mock-server/tests/cli.spec.ts | 10 +- packages/support/loader-smoke/package.json | 1 + packages/support/loader-smoke/src/index.ts | 66 +++------ pnpm-lock.yaml | 126 ++++++++++++++++++ 17 files changed, 364 insertions(+), 321 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 9fd1d55ab2..1ea7d9f0db 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1,7 +1,7 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { execa } from 'execa' import { describe, expect, it } from 'vitest' /** @@ -22,25 +22,18 @@ import { describe, expect, it } from 'vitest' const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -/** Run the built bin with PIPED stdio; resolve with output + exit code. */ -function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [dshBin], { stdio: ['pipe', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (c: string) => { stdout += c }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (c: string) => { stderr += c }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`dsh built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, 25_000) - // Resolve on `close` (all stdio drained), not `exit`, so captured output is complete. - child.on('close', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) - child.on('error', (err) => { clearTimeout(timer); reject(err) }) - child.stdin.end() +/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */ +async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { + const result = await execa(process.execPath, [dshBin], { + input: '', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, }) + if (result.timedOut) { + throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr } } describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index babfbde919..b9da65aae7 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -17,7 +17,7 @@ // the open llm seam post-boot with installLlmReplay on the settled root ctx // (the plugin-row path discards the ReplayHandle; the direct install keeps // assertConsumed for the teardown fixture-consumption check). -import { existsSync, readFileSync } from 'node:fs' +import { existsSync } from 'node:fs' import { mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -63,16 +63,6 @@ const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml') // contextWindow keeps that pressure path provably inert for small fixtures. const REPLAY_PROVIDERS = [{ id: 'deepseek', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }] -/** Repo-root .env → process.env for record mode (never overrides set vars); the smoke-real convention. */ -function loadRootEnv(): void { - const envPath = join(REPO_ROOT, '.env') - if (!existsSync(envPath)) return - for (const line of readFileSync(envPath, 'utf8').split('\n')) { - const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim()) - if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2] - } -} - /** A booted web scaffold: real composition, mode-selected model backend, temp world. */ export interface WebScaffold { /** The active snapshot mode this scaffold booted under. */ @@ -123,7 +113,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { return new Promise((resolveReady, reject) => { let out = '' diff --git a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts index cb2ab9687e..fb31afd033 100644 --- a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts +++ b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts @@ -1,4 +1,3 @@ -import { spawn } from 'node:child_process' import { createServer } from 'node:http' import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -6,6 +5,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { zstdDecompress } from 'node:zlib' +import { execa } from 'execa' import { describe, expect, it } from 'vitest' const binScript = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) @@ -69,7 +69,9 @@ describe('jsonrpc-agent keyless smoke', () => { await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) const address = modelServer.address() if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port') - const child = spawn(process.execPath, [ + // The line-predicate protocol driving below is the genuinely custom part; + // execa owns spawn, the deadline, and exit settlement around it. + const child = execa(process.execPath, [ '--import', 'tsx', binScript, @@ -77,27 +79,26 @@ describe('jsonrpc-agent keyless smoke', () => { ], { cwd: repoRoot, env: { - ...process.env, DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, DSH_CWD: root, DSH_SESSION_ROOT: join(root, '.sessions'), ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }), }, - stdio: ['pipe', 'pipe', 'pipe'], + timeout: 35_000, + killSignal: 'SIGKILL', + reject: false, }) const lines: string[] = [] let stdoutBuffer = '' let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { - stdoutBuffer += chunk + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuffer += chunk.toString('utf8') const parts = stdoutBuffer.split('\n') stdoutBuffer = parts.pop() ?? '' lines.push(...parts) }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) try { child.stdin.write(`${JSON.stringify({ @@ -144,16 +145,8 @@ describe('jsonrpc-agent keyless smoke', () => { child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr) expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} }) - if (child.exitCode === null) { - await new Promise((resolve, reject) => { - child.once('exit', (code) => { - if (code === 0) resolve() - else reject(new Error(`runtime exited ${code}; stderr=${stderr}`)) - }) - }) - } else { - expect(child.exitCode, stderr).toBe(0) - } + const exit = await child + expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0) const sessionsRoot = join(root, '.sessions') const files = await readdir(sessionsRoot, { recursive: true }) const log = files.find(file => file.endsWith('.jsonl.zstd')) @@ -162,14 +155,16 @@ describe('jsonrpc-agent keyless smoke', () => { expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' }) } finally { - if (child.exitCode === null) child.kill('SIGKILL') + // No-op after exit; reject: false settles on every outcome, so cleanup never races teardown. + child.kill('SIGKILL') + await child await new Promise(resolve => modelServer.close(() => { resolve() })) await rm(root, { recursive: true, force: true }) } }, 40_000) it('rejects an invalid max-token success env value', async () => { - const child = spawn(process.execPath, [ + const { exitCode, stdout, stderr } = await execa(process.execPath, [ '--import', 'tsx', binScript, @@ -177,22 +172,13 @@ describe('jsonrpc-agent keyless smoke', () => { ], { cwd: repoRoot, env: { - ...process.env, DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes', }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const exitCode = await new Promise((resolve, reject) => { - child.once('error', reject) - child.once('exit', resolve) + stdin: 'ignore', + timeout: 9_000, + killSignal: 'SIGKILL', + reject: false, }) expect(exitCode, stderr).toBe(1) diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts index 700c67f660..ea088a20f9 100644 --- a/examples/tui-agent/tests/pty-harness.ts +++ b/examples/tui-agent/tests/pty-harness.ts @@ -1,7 +1,7 @@ -import { spawn } from 'node:child_process' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { execa } from 'execa' import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' const POSIX_PTY_DRIVER = String.raw` @@ -94,35 +94,32 @@ async function runPosixPtySmoke( options: TuiPtySmokeOptions, timeoutMs: number, ): Promise { - return await new Promise((resolve, reject) => { - const child = spawn('python3', [ - '-c', - POSIX_PTY_DRIVER, - launch.command, - JSON.stringify(launch.args), - JSON.stringify(launch.env), - cwd, - JSON.stringify(options.actions ?? []), - String(options.expectedExitCode ?? 0), - String(timeoutMs / 1_000), - ], { stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, timeoutMs + 5_000) - child.once('error', (error) => { clearTimeout(timer); reject(error) }) - child.once('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve(stdout) - else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }) + // The driver owns the PTY deadline (`timeoutMs`); the outer execa deadline + // only backstops a wedged python3 process itself. + const result = await execa('python3', [ + '-c', + POSIX_PTY_DRIVER, + launch.command, + JSON.stringify(launch.args), + JSON.stringify(launch.env), + cwd, + JSON.stringify(options.actions ?? []), + String(options.expectedExitCode ?? 0), + String(timeoutMs / 1_000), + ], { + stdin: 'ignore', + timeout: timeoutMs + 5_000, + killSignal: 'SIGKILL', + reject: false, + stripFinalNewline: false, }) + if (result.timedOut) { + throw new Error(`${options.label} PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + if (result.failed) { + throw new Error(`${options.label} PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + return result.stdout } async function runWindowsPtySmoke( diff --git a/package.json b/package.json index 3797b24efa..0fc6244e18 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "@types/node": "^22.20.0", "@vitest/coverage-v8": "^4.1.8", "eslint": "^10.4.1", + "execa": "^10.0.0", "eslint-plugin-sonarjs": "^4.1.0", "fast-check": "^4.8.0", "js-yaml": "^4.2.0", diff --git a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts index 4c6098a2ec..5a09dd69f2 100644 --- a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +++ b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts @@ -1,7 +1,7 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { execa } from 'execa' import { describe, expect, it } from 'vitest' /** @@ -36,12 +36,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { console.log(JSON.stringify(result)) process.exit(0) ` - const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') }) - child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) - const exitCode = await new Promise(resolve => child.on('close', resolve)) + const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], { + cwd: pkgDir, + stdin: 'ignore', + timeout: 55_000, + killSignal: 'SIGKILL', + reject: false, + }) expect(exitCode, `stderr:\n${stderr}`).toBe(0) const lastLine = stdout.trim().split('\n').at(-1) ?? '' diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 02e82ac5ac..8b6533e106 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -17,6 +17,7 @@ import { import { Readable, Writable } from 'node:stream' import { promisify } from 'node:util' import { zstdDecompress } from 'node:zlib' +import { execa } from 'execa' import { afterEach, describe, expect, it } from 'vitest' /** @@ -209,25 +210,19 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n }, 30_000) }) -/** Spawn the built acp bin against `configArg` and resolve with its exit code + stderr. */ -function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> { - return new Promise((resolve, reject) => { - const proc = spawn(process.execPath, [acpBin, '--config', configArg], { - cwd, - env: { - ...process.env, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - child = proc - let stderr = '' - proc.stderr.setEncoding('utf8') - proc.stderr.on('data', (c: string) => { stderr += c }) - const timer = setTimeout(() => { proc.kill('SIGKILL'); reject(new Error(`bin did not exit within 25s. stderr:\n${stderr}`)) }, 25_000) - proc.on('exit', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stderr }) }) - proc.on('error', (err) => { clearTimeout(timer); reject(err) }) - proc.stdin.end() +/** Spawn the built acp bin against `configArg` (stdin closed at EOF) and resolve with its exit code + stderr. */ +async function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise<{ code: number; stderr: string }> { + const result = await execa(process.execPath, [acpBin, '--config', configArg], { + cwd, + env: { + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + input: '', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, }) + if (result.timedOut) throw new Error(`bin did not exit within 25s. stderr:\n${result.stderr}`) + return { code: result.exitCode ?? -1, stderr: result.stderr } } diff --git a/packages/examples/cli-demo/tests/built-bin.e2e.ts b/packages/examples/cli-demo/tests/built-bin.e2e.ts index 5c3a6ad62e..f87563d27a 100644 --- a/packages/examples/cli-demo/tests/built-bin.e2e.ts +++ b/packages/examples/cli-demo/tests/built-bin.e2e.ts @@ -1,4 +1,3 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -6,6 +5,7 @@ import { dirname, join } from 'node:path' import { promisify } from 'node:util' import { fileURLToPath } from 'node:url' import { zstdDecompress } from 'node:zlib' +import { execa } from 'execa' import { afterEach, describe, expect, it } from 'vitest' /** @@ -114,36 +114,34 @@ interface BinResult { readonly stderr: string } -function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise { - return new Promise((resolveResult, reject) => { - const child = spawn(process.execPath, [cliBin, ...args], { - cwd, - env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' +async function runBuiltBin(cwd: string, args: readonly string[], interrupt?: NodeJS.Signals): Promise { + const subprocess = execa(process.execPath, [cliBin, ...args], { + cwd, + env: { DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') }, + stdin: 'ignore', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, + stripFinalNewline: false, + }) + // Genuinely custom mid-stream logic: the signal cases deliver `interrupt` + // once the first streamed chunk proves the turn is in flight. + if (interrupt !== undefined) { + let streamed = '' let interrupted = false - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { - stdout += chunk - if (interrupt !== undefined && !interrupted && stdout.includes('assistant/chunk')) { + subprocess.stdout.on('data', (chunk: Buffer) => { + streamed += chunk.toString('utf8') + if (!interrupted && streamed.includes('assistant/chunk')) { interrupted = true - child.kill(interrupt) + subprocess.kill(interrupt) } }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`built CLI did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, 25_000) - child.once('error', (error) => { clearTimeout(timer); reject(error) }) - child.once('exit', (code, signal) => { - clearTimeout(timer) - resolveResult({ code: code ?? -1, signal, stdout, stderr }) - }) - }) + } + const result = await subprocess + if (result.timedOut) { + throw new Error(`built CLI did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + return { code: result.exitCode ?? -1, signal: result.signal ?? null, stdout: result.stdout, stderr: result.stderr } } let consumer: string | undefined diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts index a2da86d87c..ef44655b19 100644 --- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -1,9 +1,9 @@ -import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' +import { execa } from 'execa' import { afterAll, beforeAll, describe, expect, it } from 'vitest' /** @@ -57,12 +57,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { console.log(JSON.stringify(result)) await ctx.fiber.dispose() ` - const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') }) - child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) - const exitCode = await new Promise(resolve => child.on('close', resolve)) + const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], { + cwd: pkgDir, + stdin: 'ignore', + timeout: 55_000, + killSignal: 'SIGKILL', + reject: false, + }) expect(exitCode, `stderr:\n${stderr}`).toBe(0) const lastLine = stdout.trim().split('\n').at(-1) ?? '' diff --git a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts index 411e374833..8a59923bf6 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts @@ -1,10 +1,10 @@ -import { spawn } from 'node:child_process' import { access, mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import { execa } from 'execa' import { Context } from 'cordis' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import SessionStore, { SessionId, TOOL_OUTCOME_UNKNOWN, type SessionEvent, @@ -19,44 +19,36 @@ const roots: string[] = [] const CHILD_FAILPOINT_TIMEOUT_MS = 30_000 async function waitForFile(path: string): Promise { - const deadline = Date.now() + CHILD_FAILPOINT_TIMEOUT_MS - for (;;) { - try { - await access(path) - return - } catch (error: unknown) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - } - if (Date.now() >= deadline) throw new Error(`crash child did not reach failpoint ${path}`) - await new Promise(resolve => setTimeout(resolve, 10)) - } + await vi.waitFor(async () => { + await access(path).catch((error: unknown) => { + throw new Error(`crash child did not reach failpoint ${path}`, { cause: error }) + }) + }, { interval: 10, timeout: CHILD_FAILPOINT_TIMEOUT_MS }) } async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> { const root = await mkdtemp(join(tmpdir(), `dsh-semantic-${mode}-`)) roots.push(root) const marker = join(root, 'failpoint') - const child = spawn(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], { + // The SIGKILL-at-failpoint choreography stays custom: the child must die + // mid-write, so no timeout or graceful termination may reach it first. + const child = execa(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], { cwd: repoRoot, - env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, - stdio: ['ignore', 'ignore', 'pipe'], + env: { TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, + stdin: 'ignore', + stdout: 'ignore', + reject: false, }) - let stderr = '' - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) try { await waitForFile(marker) const markerText = await readFile(marker, 'utf8') - const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { - child.once('close', (code, signal) => { resolve({ code, signal }) }) - }) child.kill('SIGKILL') - const exit = await closed - expect(exit).toEqual({ code: null, signal: 'SIGKILL' }) + const exit = await child + expect({ code: exit.exitCode ?? null, signal: exit.signal ?? null }).toEqual({ code: null, signal: 'SIGKILL' }) return { root, markerText } } catch (error: unknown) { - if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') - throw new Error(`crash child failed: ${stderr}`, { cause: error }) + child.kill('SIGKILL') + throw new Error(`crash child failed: ${(await child).stderr}`, { cause: error }) } } diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index cd84f42513..0cf1cde9ab 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -21,7 +21,7 @@ import { existsSync, realpathSync } from 'node:fs' import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { basename, dirname, join, delimiter } from 'node:path' -import { setTimeout as delay } from 'node:timers/promises' +import { vi } from 'vitest' import { ClientSideConnection, PROTOCOL_VERSION, @@ -457,17 +457,25 @@ async function waitForPersistedTurnStart( timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, minimumTurn?: number, ): Promise { - const deadline = Date.now() + timeoutMs - while (true) { + let invalidRecord: Error | undefined + await vi.waitFor(async () => { const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId) - const openTurn = log === undefined ? undefined : latestOpenTurn(log.content) - if (openTurn !== undefined && (minimumTurn === undefined || openTurn >= minimumTurn)) return - if (Date.now() >= deadline) { + let openTurn: number | undefined + try { + openTurn = log === undefined ? undefined : latestOpenTurn(log.content) + } catch (error) { + // A malformed persisted record is a scenario bug, not a not-yet state: + // vi.waitFor retries every callback throw, so capture the validation + // failure, resolve the wait, and rethrow immediately below. + invalidRecord = error instanceof Error ? error : new Error(String(error)) + return + } + if (openTurn === undefined || (minimumTurn !== undefined && openTurn < minimumTurn)) { const detail = minimumTurn === undefined ? 'turn/start' : `turn/start at or beyond turn ${minimumTurn}` throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${detail} within ${timeoutMs}ms`) } - await delay(WAIT_POLL_INTERVAL_MS) - } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) + if (invalidRecord !== undefined) throw invalidRecord } /** @@ -481,15 +489,12 @@ async function waitForPersistedTurnEnd( sessionId: string, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, ): Promise { - const deadline = Date.now() + timeoutMs - while (true) { + await vi.waitFor(async () => { const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId) - if (log !== undefined && latestTurnIsClosed(log.content)) return - if (Date.now() >= deadline) { + if (log === undefined || !latestTurnIsClosed(log.content)) { throw new Error(`snapshot-harness: session "${sessionId}" did not persist turn/end within ${timeoutMs}ms`) } - await delay(WAIT_POLL_INTERVAL_MS) - } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) } /** Wait for a cwd-relative marker proving an external action reached readiness. */ @@ -499,13 +504,11 @@ async function waitForWorkspaceFile( timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, ): Promise { const target = join(cwd, path) - const deadline = Date.now() + timeoutMs - while (!existsSync(target)) { - if (Date.now() >= deadline) { + await vi.waitFor(() => { + if (!existsSync(target)) { throw new Error(`snapshot-harness: workspace file "${path}" did not appear within ${timeoutMs}ms`) } - await delay(WAIT_POLL_INTERVAL_MS) - } + }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) } /** Return whether the last complete raw-JSONL turn boundary closes its turn. */ diff --git a/packages/support/llm-mock-server/src/cli.ts b/packages/support/llm-mock-server/src/cli.ts index 786a74c0f4..1787f318ca 100644 --- a/packages/support/llm-mock-server/src/cli.ts +++ b/packages/support/llm-mock-server/src/cli.ts @@ -3,6 +3,7 @@ * @module @deepseek-ai/dsh-llm-mock-server/cli */ +import { parseArgs } from 'node:util' import { MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS } from './index.ts' import type { ConcreteMockLlmBehavior, @@ -63,14 +64,6 @@ Other: --help ` -function optionValue(argv: readonly string[], index: number, option: string): string { - const value = argv[index + 1] - if (value === undefined || value.startsWith('--')) { - throw new Error(`dsh-llm-mock-server: ${option} requires a value`) - } - return value -} - function numberValue(option: string, value: string): number { const parsed = Number(value) if (!Number.isFinite(parsed)) throw new Error(`dsh-llm-mock-server: ${option} must be a finite number`) @@ -122,66 +115,64 @@ function parseRandomWeights(raw: string): MockLlmRandomWeights { return weights } +/** parseArgs vocabulary: every documented flag; only `--repeat-last` and `--help` are boolean. */ +const CLI_OPTIONS = { + 'sequence': { type: 'string' }, + 'host': { type: 'string' }, + 'port': { type: 'string' }, + 'api-key': { type: 'string' }, + 'listen-delay-ms': { type: 'string' }, + 'repeat-last': { type: 'boolean' }, + 'seed': { type: 'string' }, + 'random-weights': { type: 'string' }, + 'success-text': { type: 'string' }, + 'partial-text': { type: 'string' }, + 'reasoning-text': { type: 'string' }, + 'chunk-size': { type: 'string' }, + 'chunk-delay-ms': { type: 'string' }, + 'disconnect-delay-ms': { type: 'string' }, + 'retry-after-ms': { type: 'string' }, + 'request-id': { type: 'string' }, + 'tool-name': { type: 'string' }, + 'tool-arguments': { type: 'string' }, +} as const + /** * Parse standalone server arguments without starting a process or listener. + * Tokenizing rides `node:util` `parseArgs` (strict, no positionals); numeric + * coercion, bounds, and cross-option constraints remain manual below it. * @param argv - arguments after the executable name. * @returns help or validated run configuration. */ export function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseResult { if (argv.includes('--help')) return { kind: 'help' } - let sequenceRaw: string | undefined - let host: string | undefined - let port = 8_000 - let apiKey: string | undefined - let listenDelayMs: number | undefined - let repeatLast = false - let randomSeed: number | undefined - let randomWeights: MockLlmRandomWeights | undefined - let successText: string | undefined - let partialText: string | undefined - let reasoningText: string | undefined - let chunkSize: number | undefined - let chunkDelayMs: number | undefined - let disconnectDelayMs: number | undefined - let retryAfterMs: number | undefined - let requestId: string | undefined - let toolName: string | undefined - let toolArguments: string | undefined + const { values } = parseArgs({ args: [...argv], options: CLI_OPTIONS, strict: true, allowPositionals: false }) - for (let index = 0; index < argv.length; index += 1) { - const option = argv[index] as string - if (option === '--repeat-last') { - repeatLast = true - continue - } - const value = optionValue(argv, index, option) - index += 1 - switch (option) { - case '--sequence': sequenceRaw = value; break - case '--host': host = value; break - case '--port': port = numberValue(option, value); break - case '--api-key': apiKey = value; break - case '--listen-delay-ms': - listenDelayMs = boundedIntegerValue(option, value, 0, MAX_MOCK_LLM_TIMER_DELAY_MS) - break - case '--seed': randomSeed = numberValue(option, value); break - case '--random-weights': randomWeights = parseRandomWeights(value); break - case '--success-text': successText = value; break - case '--partial-text': partialText = value; break - case '--reasoning-text': reasoningText = value; break - case '--chunk-size': chunkSize = numberValue(option, value); break - case '--chunk-delay-ms': chunkDelayMs = numberValue(option, value); break - case '--disconnect-delay-ms': disconnectDelayMs = numberValue(option, value); break - case '--retry-after-ms': retryAfterMs = numberValue(option, value); break - case '--request-id': requestId = value; break - case '--tool-name': toolName = value; break - case '--tool-arguments': toolArguments = value; break - default: throw new Error(`dsh-llm-mock-server: unknown option ${JSON.stringify(option)}`) - } - } + const host = values.host + const port = values.port === undefined ? 8_000 : numberValue('--port', values.port) + const apiKey = values['api-key'] + const listenDelayMs = values['listen-delay-ms'] === undefined + ? undefined + : boundedIntegerValue('--listen-delay-ms', values['listen-delay-ms'], 0, MAX_MOCK_LLM_TIMER_DELAY_MS) + const repeatLast = values['repeat-last'] ?? false + const randomSeed = values.seed === undefined ? undefined : numberValue('--seed', values.seed) + const randomWeights = values['random-weights'] === undefined ? undefined : parseRandomWeights(values['random-weights']) + const successText = values['success-text'] + const partialText = values['partial-text'] + const reasoningText = values['reasoning-text'] + const chunkSize = values['chunk-size'] === undefined ? undefined : numberValue('--chunk-size', values['chunk-size']) + const chunkDelayMs = values['chunk-delay-ms'] === undefined ? undefined : numberValue('--chunk-delay-ms', values['chunk-delay-ms']) + const disconnectDelayMs = values['disconnect-delay-ms'] === undefined + ? undefined + : numberValue('--disconnect-delay-ms', values['disconnect-delay-ms']) + const retryAfterMs = values['retry-after-ms'] === undefined ? undefined : numberValue('--retry-after-ms', values['retry-after-ms']) + const requestId = values['request-id'] + const toolName = values['tool-name'] + const toolArguments = values['tool-arguments'] - if (sequenceRaw === undefined) throw new Error('dsh-llm-mock-server: --sequence is required') + if (values.sequence === undefined) throw new Error('dsh-llm-mock-server: --sequence is required') + const sequenceRaw = values.sequence const parsedSequence = parseSequence(sequenceRaw) if (parsedSequence.startsUnavailable && port === 0) { throw new Error('dsh-llm-mock-server: connection_refused requires an explicit nonzero --port') diff --git a/packages/support/llm-mock-server/tests/cli.spec.ts b/packages/support/llm-mock-server/tests/cli.spec.ts index 12c5bd6926..04b221beda 100644 --- a/packages/support/llm-mock-server/tests/cli.spec.ts +++ b/packages/support/llm-mock-server/tests/cli.spec.ts @@ -101,8 +101,11 @@ describe('mock LLM server CLI parser', () => { it.each([ [[], /--sequence is required/], - [['--wat'], /requires a value/], - [['--wat', 'x'], /unknown option/], + // Tokenizer-level failures carry node:util parseArgs's own messages. + [['--wat'], /Unknown option '--wat'/], + [['--wat', 'x'], /Unknown option '--wat'/], + [['--port'], /Option '--port ' argument missing/], + [['--sequence', 'success', 'stray'], /Unexpected argument 'stray'/], [['--port', 'NaN', '--sequence', 'success'], /finite number/], [['--sequence', 'success,'], /non-empty/], [['--sequence', 'success,connection_refused'], /only as the first/], @@ -110,7 +113,8 @@ describe('mock LLM server CLI parser', () => { [['--sequence', 'unknown'], /unknown behavior/], [['--sequence', 'connection_refused,success', '--port', '0'], /nonzero/], [['--sequence', 'success', '--listen-delay-ms', '5'], /requires connection_refused/], - [['--sequence', 'connection_refused,success', '--listen-delay-ms', '-1'], /integer between 0 and 2147483647/], + // `=` syntax: a space-separated leading-dash value is a tokenizer error, not a bounds probe. + [['--sequence', 'connection_refused,success', '--listen-delay-ms=-1'], /integer between 0 and 2147483647/], [['--sequence', 'connection_refused,success', '--listen-delay-ms', '1.5'], /integer between 0 and 2147483647/], [['--sequence', 'connection_refused,success', '--listen-delay-ms', '2147483648'], /integer between 0 and 2147483647/], [['--sequence', 'success', '--seed', '1'], /require random/], diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json index 570ee2c5ca..ebdd62373a 100644 --- a/packages/support/loader-smoke/package.json +++ b/packages/support/loader-smoke/package.json @@ -27,6 +27,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "execa": "^10.0.0", "tsx": "^4.22.4" }, "peerDependencies": { diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index 61ad3b9d16..e573684a4e 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -11,10 +11,10 @@ * @module @deepseek-ai/dsh-loader-smoke */ -import { spawn } from 'node:child_process' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { execa } from 'execa' const DEFAULT_PROCESS_TIMEOUT_MS = 30_000 @@ -171,53 +171,27 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise((resolve, reject) => { - const child = spawn(launch.command, launch.args, { - cwd, - env: { ...process.env, ...launch.env }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - let deferredFailure: Error | undefined - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const timer = setTimeout(() => { - deferredFailure = new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`) - child.kill('SIGKILL') - }, processTimeoutMs) - - child.once('exit', (code) => { - clearTimeout(timer) - if (deferredFailure !== undefined) { - reject(deferredFailure) - } else if (code === 0) { - resolve({ stdout, stderr }) - } else { - reject(new Error(`${options.label} exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - } - }) - - // process.execPath and a just-created pipe make these OS-error paths - // impractical to induce without replacing the boundary under test. - /* v8 ignore start */ - child.once('error', (error) => { - clearTimeout(timer) - reject(new Error(`${options.label} failed to start: ${error.message}`)) - }) - child.stdin.once('error', (error) => { - deferredFailure ??= new Error(`${options.label} stdin failed: ${error.message}`) - child.kill('SIGKILL') - }) - /* v8 ignore stop */ - - child.stdin.end() + // `input: ''` writes nothing and closes stdin — the fixture-visible + // stdin-close contract. `reject: false` folds spawn errors, the SIGKILL + // deadline, and nonzero exits into independent result fields, so the + // diagnostics below embed both streams on every failure. + const result = await execa(launch.command, launch.args, { + cwd, + env: launch.env, + input: '', + timeout: processTimeoutMs, + killSignal: 'SIGKILL', + reject: false, + stripFinalNewline: false, }) + if (result.timedOut) { + throw new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + if (result.failed) { + throw new Error(`${options.label} exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } await options.inspect?.(cwd) - return result + return { stdout: result.stdout, stderr: result.stderr } } finally { await rm(cwd, { recursive: true, force: true }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1946a841bb..d202f4f665 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: eslint-plugin-sonarjs: specifier: ^4.1.0 version: 4.1.0(eslint@10.5.0(jiti@2.7.0)) + execa: + specifier: ^10.0.0 + version: 10.0.0 fast-check: specifier: ^4.8.0 version: 4.8.0 @@ -3844,6 +3847,9 @@ importers: packages/support/loader-smoke: dependencies: + execa: + specifier: ^10.0.0 + version: 10.0.0 tsx: specifier: ^4.22.4 version: 4.22.4 @@ -6719,6 +6725,9 @@ packages: cpu: [x64] os: [win32] + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@shikijs/core@2.5.0': resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} @@ -6743,6 +6752,10 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@smithy/core@3.24.7': resolution: {integrity: sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==} engines: {node: '>=18.0.0'} @@ -7885,6 +7898,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + execa@10.0.0: + resolution: {integrity: sha512-Cxl6MKxB1dr1H0FHmiizJ+lavKF7pV+fcDZFyqMB8d5m7qUPm/OtZYcD5vPWePKxSnTQ57KuBd9mtdZ3oNCvyQ==} + engines: {node: '>=22'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -7950,6 +7967,10 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -8033,6 +8054,10 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} @@ -8132,6 +8157,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -8220,6 +8249,14 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -8864,6 +8901,10 @@ packages: non-layered-tidy-tree-layout@2.0.2: resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -8933,6 +8974,10 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -8958,6 +9003,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -9018,6 +9067,10 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -9319,6 +9372,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -9513,6 +9570,10 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -9790,6 +9851,11 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-command@0.1.0: + resolution: {integrity: sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==} + engines: {node: '>=22'} + hasBin: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -9853,6 +9919,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -11350,6 +11420,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@sec-ant/readable-stream@0.4.1': {} + '@shikijs/core@2.5.0': dependencies: '@shikijs/engine-javascript': 2.5.0 @@ -11390,6 +11462,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/merge-streams@4.0.0': {} + '@smithy/core@3.24.7': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -12734,6 +12808,22 @@ snapshots: dependencies: eventsource-parser: 3.1.0 + execa@10.0.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + path-key: 4.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + which-command: 0.1.0 + yoctocolors: 2.1.2 + expect-type@1.3.0: {} express-rate-limit@8.5.2(express@5.2.1): @@ -12823,6 +12913,10 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -12919,6 +13013,11 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -13056,6 +13155,8 @@ snapshots: transitivePeerDependencies: - supports-color + human-signals@8.0.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -13115,6 +13216,10 @@ snapshots: is-promise@4.0.0: {} + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + is-what@5.5.0: {} isarray@1.0.0: {} @@ -13936,6 +14041,11 @@ snapshots: non-layered-tidy-tree-layout@2.0.2: optional: true + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -14046,6 +14156,8 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-ms@4.0.0: {} + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -14062,6 +14174,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -14110,6 +14224,10 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + process-nextick-args@2.0.1: {} property-information@7.2.0: {} @@ -14535,6 +14653,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-final-newline@4.0.0: {} + strip-json-comments@5.0.3: {} strnum@2.4.0: @@ -14691,6 +14811,8 @@ snapshots: undici@7.28.0: {} + unicorn-magic@0.3.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -15010,6 +15132,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + which-command@0.1.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -15051,6 +15175,8 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors@2.1.2: {} + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: zod: 4.4.3 From d404609e19e9f548d55bbd427a1b6a4c8d378d96 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:54:44 +0800 Subject: [PATCH 29/70] docs(subprocess): bring the zh side of the consumer-migration pairs along The parent branch's zh translations were recorded against its pre-reshape en text; this PR's Node-ward en deltas get minimal zh updates across the subprocess catalog and the subprocess/lsp-local/subagent READMEs (all type-equiv blocks byte-identical), with pairing records re-recorded. --- .../core-data-structures/subprocess.i18n.yaml | 4 +- docs/core-data-structures/subprocess.zh.md | 225 ++++++++++++++---- packages/lsp/lsp-local/README.i18n.yaml | 4 +- packages/lsp/lsp-local/README.zh.md | 2 +- packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.zh.md | 3 +- .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.zh.md | 2 +- packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.zh.md | 6 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.zh.md | 12 +- .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.zh.md | 17 +- 14 files changed, 209 insertions(+), 86 deletions(-) diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index 10be6b6d75..0527cf34a3 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/subprocess.i18n.yaml @@ -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 -subprocess.md: 2481afe324b0cbd2404186ae79670da81038f8f5 -subprocess.zh.md: 291233c456f73a1ea80de19290a6fc71a1f22725 +subprocess.md: cdd4507c7d37f47ca243ddf38114f5aa6b6f3ad1 +subprocess.zh.md: 78325c3255c42ed591bbd98fdbdb4fdfcba48202 diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 291233c456..78325c3255 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -2,13 +2,13 @@ [English](subprocess.md) | 中文 -进程管理器 seam 分为接口([dsh-subprocess](../../packages/subprocess/subprocess),`ctx.subprocess`)与实现([dsh-subprocess-local](../../packages/subprocess/subprocess-local));它的消费方是其他能力 seam:目前是 [bash 执行器家族](bash.md),后者传入 `['bash', '-c', command]` argv,并拥有每一项默认值。该 seam 拥有受管的 `DSH_*` 环境命名空间与 `CollectedOutput` 形状;[dsh-bash](../../packages/bash/bash) 将二者重导出,使 bash 消费方保持单一导入入口。 +进程管理器 seam 分为接口([dsh-subprocess](../../packages/subprocess/subprocess),`ctx.subprocess`)与实现([dsh-subprocess-local](../../packages/subprocess/subprocess-local));它的消费方是其他能力 seam 与进程外后端:[bash 执行器家族](bash.md)使用收集模式(collect)的批量输出,LSP 主机使用管道化的协议流 + 收集的 stderr 尾部,ACP(Agent Client Protocol)subagent 后端则使用管道化的协议流 + inherit 的 stderr。该 seam 拥有受管的 `DSH_*` 环境命名空间、共享的凭据清除(`scrubbedParentEnv`)与 `CollectedOutput` 形状;[dsh-bash](../../packages/bash/bash) 重导出这套词汇,使 bash 消费方保持单一导入入口。 源码:[`packages/subprocess/subprocess/src/types.ts`](../../packages/subprocess/subprocess/src/types.ts) ## 受管环境命名空间与捕获的输出 -`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方快照之前丢弃环境中已有的 `DSH_*` 名称,每条被捕获的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 +`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方快照之前丢弃环境中已有的 `DSH_*` 名称,每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 ```ts type-equiv /** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ @@ -32,83 +32,170 @@ interface CollectedOutput { } ``` -## 完全显式的 spawn spec +## Node 形状的 stdio 处置方式(disposition) -该 seam 不应用任何默认值:每项限制与目录都在 spec 上显式给出,因此由调用方自己的配置决定它们,而不是由某个隐藏的进程管理器默认值决定。`argv` 绝不经过 shell 解释。 +每条流的处置方式都显式给出,由各消费方自行选择:原始管道用于协议分帧(LSP JSON-RPC、ACP ndjson),inherit 用于直通的诊断输出,收集模式用于有界的批量输出;其中 spill 文件是可选的,因此诊断尾部(语言服务器的 stderr)可以只在内存中缓冲,不留下任何文件。 ```ts type-equiv /** - * A fully-specified spawn request. This seam applies no defaults: every limit - * and directory is explicit, so the caller's own config — not a hidden - * subprocess-service default — decides them (the `dsh-bash` request/spec split - * is the owning template). + * stdin disposition. `'ignore'` leaves fd 0 on `/dev/null`; `'pipe'` exposes + * {@link SubprocessHandle.stdin} for the caller's ongoing protocol writes; + * `{ data }` writes the bytes and closes (the batch shape). + */ +type SubprocessStdinMode = 'ignore' | 'pipe' | { readonly data: string } +``` + +```ts type-equiv +/** + * Bounded in-memory collection for one output stream, with an optional + * full-stream spill file. Omitting `spill` keeps only the in-memory tail — + * the diagnostic-tail shape (a language server's stderr); including it makes + * the complete stream recoverable up to its cap (the bash tool shape). + */ +interface SubprocessCollect { + /** In-memory cap in bytes; overflow keeps the TAIL. */ + maxBytes: number + /** Full-stream spill file; absent disables spilling entirely. */ + spill?: { + /** Whole-stream byte cap; a larger stream discards its now-incomplete spill. */ + maxBytes: number + } +} +``` + +```ts type-equiv +/** + * stdout/stderr disposition. `'pipe'` exposes the raw `Readable` for the + * caller's protocol decoding; `'inherit'` passes the parent's descriptor + * through (child diagnostics land on the harness's own stream); a + * {@link SubprocessCollect} object buffers boundedly with offset-based reads. + */ +type SubprocessOutputMode = 'pipe' | 'inherit' | SubprocessCollect +``` + +```ts type-equiv +/** Per-stream stdio dispositions, all explicit — this seam applies no defaults. */ +interface SubprocessStdio { + stdin: SubprocessStdinMode + stdout: SubprocessOutputMode + stderr: SubprocessOutputMode +} +``` + +## 完全显式的 spawn spec + +该 seam 不应用任何默认值:每项处置方式、限制与目录都在 spec 上显式给出,因此由调用方自己的配置决定它们,而不是由某个隐藏的进程管理器默认值决定。`argv` 绝不经过 shell 解释。 + +```ts type-equiv +/** + * A fully-specified spawn request. This seam applies no defaults: every + * disposition, limit, and directory is explicit, so the caller's own config — + * not a hidden subprocess-service default — decides them (the `dsh-bash` + * request/spec split is the owning template). */ interface SubprocessSpawnSpec { /** Executable and arguments; `argv[0]` is the program. Never shell-interpreted here. */ argv: readonly string[] /** Working directory for the child. */ cwd: string - /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */ - stdoutMaxBytes: number - /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ - stderrMaxBytes: number - /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ - maxSpillBytes: number - /** Grace period for kill escalation and for inherited pipes after process exit. */ + /** Per-stream stdio dispositions. */ + stdio: SubprocessStdio + /** + * Grace period in milliseconds for the {@link SubprocessHandle.terminate} + * escalation and for draining still-open collected pipes after the process + * exits (an inherited descriptor held by a surviving descendant cannot hold + * the outcome open indefinitely). + */ 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. + * Abort signal — starts the terminate escalation on the process tree 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}. + * Ordinary environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`). `DSH_*` names are rejected and + * belong in {@link dshEnv}; a deliberately forwarded credential-shaped + * entry survives because this layer merges after the scrub. */ env?: Record | 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. + * Harness-owned `DSH_*` variables for this execution. The scrubbed base has + * already discarded ambient `DSH_*` entries, so an unavailable current fact + * cannot inherit a stale value from the harness process; non-`DSH_*` names + * on this channel are rejected. */ dshEnv?: DshEnvironment | undefined } ``` -## 句柄与基于偏移量的读取 +## 句柄:流、读取器与以进程树为范围的终止 -spawn 会立即返回一个实时句柄。输出读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;bash 工具呈现的消费游标模型,是消费方在这些读取器之上自行持有的状态。 +spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`kill(signal)` 以 Node 风格只发送一个信号,`terminate()` 执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树,`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。 ```ts type-equiv /** - * A live child process. `kill()` starts the group SIGTERM→grace→SIGKILL - * escalation; buffered output remains readable after exit. + * A live child process rooted in its own process tree. Collected output + * remains readable after exit; piped streams belong to the caller. + * + * Termination is tree-scoped everywhere: POSIX signals the detached process + * group (falling back to the direct child when the group is gone), Windows + * terminates the tree via `taskkill /T`, so helper processes cannot outlive + * the handle unnoticed. */ interface SubprocessHandle { - /** Process id (group leader); -1 when the spawn itself failed. */ + /** Process id (tree root); -1 when the spawn itself failed. */ readonly pid: number - /** Live stdout reader (also readable after exit). */ - readonly stdout: SubprocessOutputReader - /** Live stderr reader (also readable after exit). */ - readonly stderr: SubprocessOutputReader - /** Resolves when the process closes; rejects only for spawn-level failures. */ + /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */ + readonly stdin: Writable | undefined + /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */ + readonly stdout: Readable | undefined + /** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */ + readonly stderr: Readable | undefined + /** Offset-based readers for collect-mode streams (also readable after exit). */ + readonly collected: SubprocessCollectedOutputs + /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ readonly done: Promise - /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ - kill(): void + /** + * Send one signal to the process tree, Node-style — no escalation, no + * timers. A no-op after the outcome has settled (the pid may be reused). + * @param signal - the signal to deliver (default `SIGTERM`; Windows + * force-terminates the tree for any value). + */ + kill(signal?: NodeJS.Signals): void + /** + * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree + * (Windows force-terminates immediately). Idempotent; also triggered by the + * spec's abort signal. + */ + terminate(): void + /** + * Wait until the process tree has exited — the tree, not just the direct + * child, so a still-running helper is observable before teardown returns. + * @param signal - optional bound for the wait. + * @returns `true` when the tree exited, `false` when the signal aborted first. + */ + waitForExit(signal?: AbortSignal): Promise + /** + * Tear the child down to quiescence, resolving only after exit: close stdin + * (when this handle owns a piped one) and allow cooperative flush for + * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced + * tree termination with a final bounded `graceMs` wait. + * @param graces - the ladder's two windows, from the consumer's Config. + * @throws when the child still has not exited `graceMs` after the forced tier. + */ + dispose(graces: SubprocessDisposeGraces): Promise } ``` ```ts type-equiv /** - * Cursor-free incremental access to one live output stream. Offsets are + * Cursor-free incremental access to one collected output stream. Offsets are * whole-stream byte coordinates owned by the caller, so independent readers - * cannot consume one another's output. + * cannot consume one another's output; `readFrom(0)` after settlement is the + * batch result (`lossy` then means the in-memory tail lost its head — the + * {@link CollectedOutput.truncated} fact). */ interface SubprocessOutputReader { /** @@ -136,26 +223,62 @@ interface SubprocessOutputRead { } ``` -## 结果不携带原因分类 - -`done` 报告原始退出事实。服务会在中止时终止进程,但绝不判定原因:调用方读取归自己所有的 deadline 信号,以区分超时与取消(即 bash 执行器的 `timedOut`/`aborted` 拆分)。 +```ts type-equiv +/** Offset-based readers for the streams spawned in collect mode. */ +interface SubprocessCollectedOutputs { + /** Present iff stdout is a {@link SubprocessCollect}. */ + readonly stdout?: SubprocessOutputReader + /** Present iff stderr is a {@link SubprocessCollect}. */ + readonly stderr?: SubprocessOutputReader +} +``` ```ts type-equiv /** - * Raw outcome of one closed process. Deliberately carries NO timeout or - * cancellation classification: the service kills on abort but does not decide - * why — the caller reads the signal it owns to classify causes. + * The two grace periods of the cooperative dispose ladder + * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted, + * validated Config fields, so teardown timing is deployment-tunable and this + * seam hardcodes nothing. + */ +interface SubprocessDisposeGraces { + /** + * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce + * ON ITS OWN — flush durable state, tear down its own descendants — before + * escalation to platform termination. Usually WIDER than + * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven + * teardown may itself wait on a signal-trapping grandchild plus a final + * flush. + */ + eofGraceMs: number + /** + * Termination confirmation window (ms): POSIX applies it after `SIGTERM` + * and again after `SIGKILL`; Windows applies it after the forced tree + * termination. + */ + graceMs: number +} +``` + +## 结果只承载退出事实 + +`done` 报告 Node close 事件的词汇,不携带原因分类:服务会在中止时终止进程,但绝不判定原因(调用方读取归自己所有的 deadline 信号,例如 bash 执行器的 `timedOut`/`aborted` 拆分)。收集到的输出在结算后仍可经 `handle.collected` 读取,因此批量与流式调用方共用一条访问路径。 + +```ts type-equiv +/** + * Exit facts of one closed process — Node's `close`-event vocabulary. + * Deliberately carries NO timeout or cancellation classification (the caller + * reads the signal it owns to classify causes) and NO output: collected + * streams stay readable through {@link SubprocessHandle.collected} after + * settlement, so batch and streaming callers share one access path. */ interface SubprocessOutcome { /** Exit code; null when the process died from a signal. */ exitCode: number | null /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ signal: NodeJS.Signals | null - stdout: CollectedOutput - stderr: CollectedOutput } ``` ## 服务行为 -抽象的 [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam 只定义 `spawn`;[`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) 是本地实现(detached 进程组、以 spill 文件兜底的尾部保留收集、凭据清除、先终止再等待退出的 dispose(资源释放))。seam 契约见 [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md),具体机制见 [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md)。 +抽象的 [`SubprocessService`](../../packages/subprocess/subprocess/src/index.ts) seam 只定义 `spawn`;[`LocalSubprocessService`](../../packages/subprocess/subprocess-local/src/index.ts) 是本地实现(detached 进程树、按处置方式接线的流、凭据清除、先终止再等待退出的 dispose(资源释放))。seam 契约见 [`dsh-subprocess`](../../packages/subprocess/subprocess/README.md),具体机制见 [`dsh-subprocess-local`](../../packages/subprocess/subprocess-local/README.md)。 diff --git a/packages/lsp/lsp-local/README.i18n.yaml b/packages/lsp/lsp-local/README.i18n.yaml index dcef438cf7..9532bc1cb3 100644 --- a/packages/lsp/lsp-local/README.i18n.yaml +++ b/packages/lsp/lsp-local/README.i18n.yaml @@ -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 -README.md: 877c131ca4e34fdce59a46f820b889a1b9a73555 -README.zh.md: 58cf5a0558c680abd12b599ac7ef7696ce044877 +README.md: 462cb12ce96dbbb645c9a19126911d32d4ddd722 +README.zh.md: f5537a416c49106b128188efe4adb2d65304320a diff --git a/packages/lsp/lsp-local/README.zh.md b/packages/lsp/lsp-local/README.zh.md index 58cf5a0558..f5537a416c 100644 --- a/packages/lsp/lsp-local/README.zh.md +++ b/packages/lsp/lsp-local/README.zh.md @@ -12,7 +12,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) - 每个 `(server id, canonical workspace realpath)` 惰性 single-flight 一个服务器进程。存活服务器错误不会回放;如果选中的池化传输在只读查询之前或期间失败,提供方会等待其释放,并在新进程上重试该查询一次。 - 每次查询都使用兼容性优先的**临时打开** 序列:通过 Node API 规范化并读取源文件、`textDocument/didOpen`(版本 1、完整文本)、所请求操作,然后执行 `textDocument/didClose`,该操作位于 `finally` 中。写入 `didOpen` 失败或取消时,会先终止实例再允许池复用。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。 - 通过一条逐 Workspace、可中止的队列,串行执行每个源读取/打开/查询/关闭生命周期,因此排队调用只会在轮到自身时读取当前源;不同 Workspace 并行运行。 -- 协议 shutdown 失败后,通过 POSIX 进程组信号或同步 Windows `taskkill /T /F` 终止服务器后代树。Windows 只抑制 taskkill 报告的树已不存在结果;命令、权限与其他树终止失败仍保持可见。 +- 协议 shutdown 失败后,经由进程管理器 seam 终止服务器后代树(POSIX 进程组信号;Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认。 - 通过子进程 host namespace 中的 Node 文件系统 API 读取源文件,绝不使用 `ctx.fs`,也不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。 ## 配置 diff --git a/packages/subagent/README.i18n.yaml b/packages/subagent/README.i18n.yaml index 7be70e0fe5..17e68d1607 100644 --- a/packages/subagent/README.i18n.yaml +++ b/packages/subagent/README.i18n.yaml @@ -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 -README.md: 5e3bddc67d213d74766a75da65cc44a21c8bb149 -README.zh.md: 4391809ee83c822fcada25f0bdc021af44be9354 +README.md: 8414836efd756f60258566ae3e4e00de2d4110d7 +README.zh.md: d32228495cd6c57398c88cea92ce168ecf278188 diff --git a/packages/subagent/README.zh.md b/packages/subagent/README.zh.md index 4391809ee8..d32228495c 100644 --- a/packages/subagent/README.zh.md +++ b/packages/subagent/README.zh.md @@ -10,10 +10,9 @@ subagent seam 允许 agent(智能体)把工作委派给子 agent。与 [bash | `subagent-inprocess/` | 共享进程内运行驱动器(不提供提供方;每次运行使用一个清理 effect) | 无 | | `subagent-spawn/` | 进程内后端:全新的子 agent | (注册到 `ctx.subagents`) | | `subagent-fork/` | 进程内后端:以父 agent 已完成轮次的前缀作为初始内容的子 agent | (注册到 `ctx.subagents`) | -| `subagent-subprocess/` | 共享进程外机制:环境变量清理、dispose(资源释放)阶梯、隔离配置目录(纯库;不注册任何内容) | 无 | | `subagent-acp/` | 进程外后端:在派生子进程中运行并通过 ACP(Agent Client Protocol)驱动的子 agent | (注册到 `ctx.subagents`) | | `tool-subagent/` | 面向模型的 `subagent` 委派工具,基于 `ctx.subagents` | (注册到 `ctx.tools`) | -接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` 后端则构建于 `subagent-subprocess` 库之上(凭据环境变量清理、dispose 阶梯、隔离配置目录)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 +接口位于 `subagent/subagent/`。进程内 `subagent-spawn` / `subagent-fork` 后端共享 `subagent-inprocess` 驱动器(一个自身不提供提供方的库:两者都依赖它,彼此不依赖),进程外 `subagent-acp` 后端则经由 [`subprocess/`](../subprocess/README.md) seam spawn 其子进程(共享的凭据清除、以进程树为范围的拆卸、dispose(资源释放)阶梯)。测试只用包内 fixture(测试前置数据)替换子 agent 边界。 提案与设计理由见 [.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)。 diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 72377b5f77..10d4b9a7cb 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/README.i18n.yaml @@ -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 -README.md: d1ba03cf5256ad4889c4893bfe11af42bd627f9d -README.zh.md: 5763ee9a22c1d0bfe12c7da2b7d996911b55cc49 +README.md: 317517f64f24d8a3ed01ebae08dcfd13668b9029 +README.zh.md: e10f435b13e7cdabb92ebfa5b4a5d0763f2af18f diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index 5763ee9a22..e10f435b13 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 ## 进程边界 -子进程环境由 [`buildChildEnv`](../subagent-subprocess/README.md) 构建:先移除名称形似凭据的环境变量,再应用显式 `config.env` 值。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 +子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除名称形似凭据的环境变量,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来),stderr 以 inherit 方式直通父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index 64edcdc78b..5224774c27 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.i18n.yaml @@ -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 -README.md: f91609dc6b6e27fc26e5ffb4b7fd68fc9f4de556 -README.zh.md: d4e2cd69d21772834e0e55db2b14e19ea7ce1832 +README.md: 657855aff67230ee22b8137ae3aabc76aff8f860 +README.zh.md: 5281a0d6eddb38974d1225220bab08880224f14b diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index d4e2cd69d2..5281a0d6ed 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -spawn 受管子进程组的共用归属位置:完全显式的 spawn spec、附带 spill 文件的有界尾部保留输出、经凭据清除的环境、基于偏移量的增量读取,以及 SIGTERM→宽限期→SIGKILL 的进程组终止。命令默认值补全、shell 语义、deadline 与呈现留在消费方:[bash 执行器家族](../bash/README.md)是第一个消费方,也拥有上述各项。参见[进程管理器 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 +spawn 受管子进程树的共用归属位置:完全显式的 spawn spec,其 stdio 处置方式(disposition)为 Node 形状、按流划分(原始管道、inherit、附带 spill 文件的有界尾部保留收集);harness 中所有 spawn 调用方共用的那一份凭据清除;基于偏移量的增量读取;以进程树为范围、带 SIGTERM→宽限期→SIGKILL 升级的信号发送;以及协作式 dispose(资源释放)阶梯。命令默认值补全、shell 语义、deadline、协议分帧与呈现留在消费方:[bash 执行器](../bash/README.md)、[LSP 主机](../lsp/README.md)与 [ACP(Agent Client Protocol)subagent 后端](../subagent/README.md)。参见[进程管理器 seam Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 | 包(package) | ctx 键 | 角色 | |---|---|---| -| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式的 `SubprocessSpawnSpec`、携带基于偏移量读取器的 `SubprocessHandle`,以及共享的 `DSH_*` 受管环境与 `CollectedOutput` 词汇 | -| [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地实现:detached 进程组、附带有界私有 spill 文件的尾部保留截断、凭据清除与 `DSH_*` 合并次序、kill 升级,以及先终止再等待退出的 dispose(资源释放) | +| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式的 `SubprocessSpawnSpec`、`SubprocessHandle`(流、基于偏移量的读取器、kill/terminate/waitForExit/dispose),以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 | +| [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地实现:detached 进程树、按处置方式接线的流、附带有界私有 spill 文件的尾部保留截断、`DSH_*` 合并次序、带升级的进程树信号发送、dispose 阶梯,以及先终止再等待退出的 dispose | 服务拥有跨消费方重载的进程存续期;消费方拥有一个进程的含义(一条 bash 命令、未来的非 shell 运行器)以及塑造它的每一项默认值。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 4dab94d5b8..2e8c1fdae4 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -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 -README.md: a18772055d33369f6feec1b1a0751bb299303e04 -README.zh.md: bc829d48e846055853753522287967cc9fd42ca6 +README.md: 08cc2ce7d92569222b99992d0f4c43551a2c9623 +README.zh.md: da230ba37d406a6ad4ec669ceead45f2c2dd7069 diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index bc829d48e8..da230ba37d 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -2,15 +2,15 @@ [English](README.md) | 中文 -[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地实现:`LocalSubprocessService` 把每个 spec 的 argv 作为 detached 进程组 spawn,收集有界输出,并用限制大小的完整流 spill 文件保留超量内容,随后针对整个进程组从 SIGTERM 逐步升级为 SIGKILL。该实现没有任何配置:每项限制与目录都随 spawn spec 到达,因此随部署变化的旋钮留在调用方 seam 的配置里(目前是 [`dsh-bash-local`](../../bash/bash-local/README.md))。 +[`@deepseek-ai/dsh-subprocess`](../subprocess/README.md) seam 的本地实现:`LocalSubprocessService` 把每个 spec 的 argv 作为 detached 进程树 spawn,依照 spec 中按流划分的 stdio 处置方式(disposition)完成接线(原始管道、inherit、附带可选 spill 文件的有界尾部保留收集),并以进程树为范围、按 SIGTERM→SIGKILL 升级发送信号。该实现没有任何配置:每项处置方式、限制与目录都随 spawn spec 到达,因此随部署变化的旋钮留在各调用方 seam 的配置里([`dsh-bash-local`](../../bash/bash-local/README.md)、[`dsh-lsp-local`](../../lsp/lsp-local/README.md)、[`dsh-subagent-acp`](../../subagent/subagent-acp/README.md))。 ## 行为(以及设计来源) -- **带升级的 detached 进程组**:子进程使用 `detached` spawn(拥有独立进程组);终止时先向该组发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束)。组长进程退出后,继承的 stdout/stderr 管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地阻止这次 spawn 结束。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 -- **尾部保留截断 + 有界 spill 文件**:输出超过某条流的上限后,内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),同时将完整流追加到一个私有临时文件,并在可用时报告该路径。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。 +- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 +- **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。 - **凭据清除 + 受管 `DSH_*` 合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的普通 `env` 在清除后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止陈旧的嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 -- **基于偏移量的读取**:`SubprocessHandle` 的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存。 -- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,只为让自身的 dispose 能终止每个仍在运行的进程组并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 +- **基于偏移量的读取**:收集模式的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 +- **先终止再等待退出的 dispose**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 ## 模型体验 @@ -22,7 +22,7 @@ ## 已知限制与暂缓事项 -- **仅支持 POSIX**:detached 进程组、进程组终止以及 SIGTERM→SIGKILL 升级都已硬编码;不支持 Windows。 +- **Windows 进程树支持仅为尽力而为,且未经 CI 测试**:终止经由 `taskkill /PID /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界;测试套件只通过注入的运行器覆盖这条路由,且 `packages/subprocess/*` 被排除在 Windows 测试矩阵之外。 - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 - **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index adc9ed8cc4..818069e978 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -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 -README.md: 126f0fd6863739563b8d3cb5b4b958cee47dfb3d -README.zh.md: fdd5035867316349a91d1700cbf3f521a2bac117 +README.md: 73e0a4abe49e8d3060f246218694faee127668e9 +README.zh.md: c38a1dd7c15e7d1c0f3139f8942911a4cd9f23fe diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index fdd5035867..c38a1dd7c1 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -6,13 +6,14 @@ ## 契约 -- `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时 resolve,仅在 spawn 层面失败时 reject。 -- spec 完全显式(argv、cwd、按流划分的字节上限、spill 上限、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 在这里绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 -- 输出读取器接受全流字节偏移量且从不消费:独立的读取器不会抢走彼此的增量。偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在完整流 spill 文件存在时指向它。 -- `kill()` 与 spec 的 abort 信号对整个 detached 进程组执行 SIGTERM→宽限期→SIGKILL 升级;服务响应中止但绝不判定原因(deadline 与原因分类归调用方所有)。 -- dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 +- `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。 +- spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 +- stdio 按流采用 Node 形状:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 +- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。 +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入该函数。 +- 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 -参见[进程数据结构目录](../../../docs/core-data-structures/subprocess.md)与 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 +参见[进程管理器数据结构目录](../../../docs/core-data-structures/subprocess.md)与 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 ## 模型体验 @@ -24,5 +25,5 @@ ## 已知限制与暂缓事项 -- **目前只有一个消费方家族**:该 seam 的形状仅在 bash 执行器上得到验证;仓库内其他 spawn 调用点(LSP 服务器、PTY 后端、subagent 传输层)继续保留各自专属的进程处理,直到它们的流与生命周期需求对照本契约得到重新审视。 -- **假定 POSIX 进程组语义**:句柄词汇(作为组长的 `pid`、进程组终止、SIGTERM/SIGKILL 升级)没有 Windows 方案。 +- **node-pty 与由 SDK 管理的 spawn 只共享凭据清除**:PTY 后端的终端 fork 与 MCP SDK 自己的 stdio 传输层无法把 spawn 路由到这道 seam(fork/spawn 调用归库所有);它们改为导入 `scrubbedParentEnv`,使环境策略保持单一来源。 +- **dispose 阶梯假定子进程配合 stdin EOF**:依赖其他信号(SIGHUP 惯例、控制 socket)才能完全停稳的子进程,需要自己的第一阶,通用阶梯才适用。 From cb3a1302eed107fcb9b783afaa624b8d1a148d76 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:55:05 +0800 Subject: [PATCH 30/70] ci: adopt pnpm/action-setup for symmetric pnpm store caching Convert the hand-rolled corepack + store-path + actions/cache pnpm setups to pnpm/action-setup@v4 + setup-node cache: pnpm (the landlock-run.yml pattern) in e2e.yml, docs-pages.yml, pi-ai-provider-e2e.yml, build-exe-for-python-sdk.yml, and ci.yml's node-compat and benchmark jobs. The enterprise restore-only PR jobs and the Windows job keep their documented asymmetries; serial-linux keeps its hand-rolled store cache because it produces the exact key those restore-only jobs consume, and only swaps corepack for the action. Implements and moves the CI-caching Agent Note from PR #667 to implemented/, recording the serial-linux producer exception and the exact-key (no restore-keys prefix) behavior of setup-node's cache. --- ...n-setup-for-symmetric-ci-caching.i18n.yaml | 4 +- ...m-action-setup-for-symmetric-ci-caching.md | 32 ++++++++ ...ction-setup-for-symmetric-ci-caching.zh.md | 32 ++++++++ ...m-action-setup-for-symmetric-ci-caching.md | 31 -------- ...ction-setup-for-symmetric-ci-caching.zh.md | 31 -------- .../workflows/build-exe-for-python-sdk.yml | 20 ++--- .github/workflows/ci.yml | 76 ++++++------------- .github/workflows/docs-pages.yml | 17 +---- .github/workflows/e2e.yml | 17 +---- .github/workflows/pi-ai-provider-e2e.yml | 17 +---- 10 files changed, 102 insertions(+), 175 deletions(-) rename .agents/notes/{proposed => implemented}/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml (59%) create mode 100644 .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md create mode 100644 .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md delete mode 100644 .agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md delete mode 100644 .agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md diff --git a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml similarity index 59% rename from .agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml rename to .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml index 0a31f7a857..23f0580c07 100644 --- a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml @@ -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 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 63e3f45ab2340ee2b732da286117e25be45bed08 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 2348e07d58f7f0ed39a1759cc30133c8e15dbc4a +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 0cc0a09dfe1019d80b08599a245ce4bb044d81c5 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 477f8eed61579eb9c4f18701a39307daf098407d diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md new file mode 100644 index 0000000000..0cc0a09dfe --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md @@ -0,0 +1,32 @@ +# Agent Note: Use pnpm/action-setup for symmetric CI pnpm caching + +Status: implemented + +English | [中文](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md) + +## Problem + +Five workflows repeated a hand-rolled three-step pnpm setup — `corepack enable`, `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml` (~40–60 YAML lines total). The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — was already proven in-repo in `landlock-run.yml`, and also insulates against corepack's removal from newer Node distributions. + +## Decision + +The symmetric-cache setups use `pnpm/action-setup@v4` followed by `actions/setup-node` with `cache: pnpm`, the `landlock-run.yml` pattern: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input, mirroring the required Windows job's deliberate skip; the consolidated benchmark caches on both platforms as before. + +Explicitly NOT converted: + +- the three enterprise-runner PR jobs in `ci.yml` — they deliberately use `actions/cache/restore` only, keeping cache compression/upload off the paid latency-critical path, an asymmetry `setup-node`'s cache cannot express; +- the Windows job, which deliberately skips the store cache; +- the store-cache step of `ci.yml`'s serial-linux job — the job swaps `corepack enable` for `pnpm/action-setup@v4`, but its `pnpm store path` + `actions/cache@v4` steps stay hand-rolled because the master-push serial-linux run is the save side that populates the exact key and path the enterprise restore-only jobs consume; converting the producer to `setup-node`'s own key format would silently starve their restores. + +## Alternatives considered + +- **Keep the hand-rolled steps.** They work, but they are five drifting copies of setup boilerplate, and the corepack dependency is a known future break. +- **Convert everything including the enterprise jobs.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority. +- **Convert serial-linux's store cache too.** Rejected during implementation: the proposal counted serial-linux among the symmetric setups, but its cache step is the producer half of the enterprise jobs' restore-only pairing — moving it to `setup-node`'s key format is the enterprise conversion by another route. + +## Consequences + +- The corepack dependency is gone from every converted workflow; pnpm arrives via the pnpm team's official action, already trusted in-repo (`landlock-run.yml`). +- The cache-key format changed once; one cold run per converted lane repopulates it, after which hit rates match the old steps. The built-in key spans platform, arch, and the lockfile hash but not the Node version, so the node-compat matrix legs share one store entry — safe, because the pnpm store is Node-version-independent. +- `setup-node`'s built-in pnpm cache restores by exact key only, with no `restore-keys` prefix fallback: a `pnpm-lock.yaml` change starts a converted lane from a cold store instead of seeding from the previous entry. +- About 75 net lines of workflow YAML removed; the enterprise-runner PR jobs and the Windows job are byte-identical, and serial-linux keeps producing the key they restore. diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md new file mode 100644 index 0000000000..477f8eed61 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 用 pnpm/action-setup 实现对称的 CI pnpm 缓存 + +Status: implemented + +[English](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md) | 中文 + +## 问题 + +五个工作流曾重复着同一套手写(hand-rolled)的三步 pnpm 设置——`corepack enable`、`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业(合计约 40–60 行 YAML)。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——当时已在仓库内的 `landlock-run.yml` 中得到验证,同时还能隔绝 corepack 被从较新 Node 发行版中移除的影响。 + +## 决策 + +各对称缓存设置现采用 `pnpm/action-setup@v4` 后接带 `cache: pnpm` 的 `actions/setup-node`,即 `landlock-run.yml` 的模式:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux,与必需 Windows 作业刻意跳过缓存的做法保持一致;consolidated benchmark 与之前一样在两个平台上都启用缓存。 + +以下明确未做转换: + +- `ci.yml` 中运行在企业 runner 上的三个 PR(Pull Request)作业——它们刻意只用 `actions/cache/restore`,把缓存压缩/上传挡在付费且延迟敏感的关键路径之外,这种不对称是 `setup-node` 的缓存无法表达的; +- Windows 作业,它刻意跳过 store 缓存; +- `ci.yml` 中 serial-linux 作业的 store 缓存步骤——该作业把 `corepack enable` 换成了 `pnpm/action-setup@v4`,但其 `pnpm store path` + `actions/cache@v4` 步骤仍保持手写,因为 master 推送触发的 serial-linux 运行正是写入侧,负责填充企业只恢复不上传(restore-only)作业所消费的那个精确缓存键与路径;把生产者改成 `setup-node` 自有的键格式,会悄然断供它们的恢复。 + +## 曾考虑的替代方案 + +- **保留手写步骤。** 它们能用,但那是五份会各自漂移的设置样板副本,而且对 corepack 的依赖是已知的未来失效点。 +- **连企业作业在内全部转换。** 否决:只恢复不上传的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。 +- **连 serial-linux 的 store 缓存也转换。** 实现期间否决:提案曾把 serial-linux 计入对称设置,但其缓存步骤是企业作业只恢复不上传配对中的生产者一半——把它改成 `setup-node` 的键格式,等于换条路径做了企业作业的转换。 + +## 后果 + +- 每个已转换的工作流都不再依赖 corepack;pnpm 经由 pnpm 团队的官方 action 提供,该 action 已在仓库内获得信任(`landlock-run.yml`)。 +- 缓存键格式变更了一次;每条已转换泳道各跑一次冷运行以重建缓存,此后命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 矩阵的各条腿共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。 +- `setup-node` 内建的 pnpm 缓存只按精确键恢复,没有 `restore-keys` 前缀回退:`pnpm-lock.yaml` 一旦变更,已转换泳道会从冷 store 起步,而不是从上一条缓存记录播种。 +- 净删除约 75 行工作流 YAML;企业 runner 上的 PR 作业与 Windows 作业逐字节未动,serial-linux 继续生产它们所恢复的缓存键。 diff --git a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md deleted file mode 100644 index 63e3f45ab2..0000000000 --- a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Use pnpm/action-setup for symmetric CI pnpm caching - -Status: proposed - -English | [中文](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md) - -## Problem - -Five workflows repeat a hand-rolled three-step pnpm setup — `corepack enable`, `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml` (~40–60 YAML lines total). The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — is already proven in-repo in `landlock-run.yml`, and also insulates against corepack's removal from newer Node distributions. - -## Proposal - -Convert the symmetric-cache workflows to `pnpm/action-setup@v4` + `setup-node` `cache: pnpm`. Explicitly do NOT convert: - -- the three enterprise-runner PR jobs in `ci.yml` — they deliberately use `actions/cache/restore` only, keeping cache compression/upload off the paid latency-critical path, an asymmetry `setup-node`'s cache cannot express; -- the Windows job, which deliberately skips the store cache. - -## Alternatives considered - -- **Keep the hand-rolled steps.** They work, but they are five drifting copies of setup boilerplate, and the corepack dependency is a known future break. -- **Convert everything including the enterprise jobs.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority. - -## Acceptance criteria - -- The five symmetric workflows set up pnpm via the actions; one cold run per lane repopulates the new cache-key format, after which cache hit rates match the old steps. -- The enterprise-runner PR jobs and the Windows job are untouched. - -## Risks - -- Cache-key format changes once (one cold run per lane). -- A third-party action in more workflows; it is already trusted in-repo (`landlock-run.yml`) and is the pnpm team's official action. diff --git a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md deleted file mode 100644 index 2348e07d58..0000000000 --- a/.agents/notes/proposed/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: 用 pnpm/action-setup 实现对称的 CI pnpm 缓存 - -Status: proposed - -[English](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md) | 中文 - -## 问题 - -五个工作流重复着同一套手写(hand-rolled)的三步 pnpm 设置——`corepack enable`、`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业(合计约 40–60 行 YAML)。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——已在仓库内的 `landlock-run.yml` 中得到验证,同时还能隔绝 corepack 被从较新 Node 发行版中移除的影响。 - -## 提案 - -将各对称缓存工作流改为 `pnpm/action-setup@v4` + `setup-node` `cache: pnpm`。以下明确不做转换: - -- `ci.yml` 中运行在企业 runner 上的三个 PR(Pull Request)作业——它们刻意只用 `actions/cache/restore`,把缓存压缩/上传挡在付费且延迟敏感的关键路径之外,这种不对称是 `setup-node` 的缓存无法表达的; -- Windows 作业,它刻意跳过 store 缓存。 - -## 曾考虑的替代方案 - -- **保留手写步骤。** 它们能用,但那是五份会各自漂移的设置样板副本,而且对 corepack 的依赖是已知的未来失效点。 -- **连企业作业在内全部转换。** 否决:只恢复不上传(restore-only)的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。 - -## 验收标准 - -- 五个对称工作流经由上述 action 完成 pnpm 设置;每条泳道各跑一次冷运行以重建新的缓存键格式,此后缓存命中率与旧步骤持平。 -- 企业 runner 上的 PR 作业与 Windows 作业保持原样不动。 - -## 风险 - -- 缓存键格式变更一次(每条泳道各一次冷运行)。 -- 更多工作流引入一个第三方 action;它已在仓库内获得信任(`landlock-run.yml`),且是 pnpm 团队的官方 action。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 48c96e4d20..b119a548b8 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -124,9 +124,14 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + + # setup-node's built-in pnpm store cache keys on platform AND arch, so + # the Linux architectures sharing runner.os stay on separate caches. - uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm - uses: actions/setup-python@v6 with: @@ -135,21 +140,6 @@ jobs: - name: Install Python build tooling run: python -m pip install uv==0.11.23 - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - # Linux architectures share runner.os, so the cache key includes arch. - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm- - # Cache pkg's target Node binary; lockfile changes roll the # exact key while the restore prefix can seed its replacement. - uses: actions/cache@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eceefa114..272bfd4453 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -236,22 +236,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} - - - name: Enable corepack and resolve pnpm store path - id: pnpm-store - run: | - corepack enable - echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ matrix.node }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ matrix.node }}-pnpm- + cache: pnpm - name: Install (immutable) run: pnpm install --frozen-lockfile @@ -324,17 +314,22 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack and resolve pnpm store path + - name: Resolve pnpm store path id: pnpm-store - run: | - corepack enable - echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" # Master refreshes the caches that pull requests restore without saving. + # The store cache stays a hand-rolled actions/cache step rather than + # setup-node's `cache: pnpm`: the enterprise pull-request jobs above + # restore exactly this key and path, and setup-node's built-in cache + # uses its own key format — converting this producer would silently + # starve their documented restore-only optimization. - uses: actions/cache@v4 with: path: ${{ steps.pnpm-store.outputs.path }} @@ -498,9 +493,14 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + + # The Windows lanes deliberately skip the store cache like the required + # windows job; an empty cache input disables setup-node's caching. - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + cache: ${{ matrix.platform == 'linux' && 'pnpm' || '' }} - name: Report runner capacity run: >- @@ -508,22 +508,6 @@ jobs: console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length, memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))" - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path - if: matrix.platform == 'linux' - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - if: matrix.platform == 'linux' - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - name: Install (immutable) run: pnpm install --frozen-lockfile @@ -598,9 +582,14 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + + # Unlike the larger-runner suite, both platforms cache the store here: + # the consolidated topology measures cache mechanics as workload. - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + cache: pnpm - name: Report runner capacity run: >- @@ -608,27 +597,6 @@ jobs: console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length, memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))" - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path (Linux) - if: matrix.platform == 'linux' - id: pnpm-store-linux - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - name: Resolve pnpm store path (Windows) - if: matrix.platform == 'windows' - id: pnpm-store-windows - shell: pwsh - run: '"path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT' - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store-linux.outputs.path || steps.pnpm-store-windows.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: actions/cache@v4 if: matrix.platform == 'linux' with: diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index e1ad20997d..281e931c50 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -33,23 +33,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + cache: pnpm - name: Install (immutable) run: pnpm install --frozen-lockfile diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3d1bba6c17..c445034a8c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -61,23 +61,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: 24 - - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-24-pnpm- + cache: pnpm - name: Install (immutable) run: pnpm install --frozen-lockfile diff --git a/.github/workflows/pi-ai-provider-e2e.yml b/.github/workflows/pi-ai-provider-e2e.yml index d198abf5b5..1306754d4c 100644 --- a/.github/workflows/pi-ai-provider-e2e.yml +++ b/.github/workflows/pi-ai-provider-e2e.yml @@ -27,23 +27,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: 24 - - - name: Enable corepack (pnpm) - run: corepack enable - - - name: Resolve pnpm store path - id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-24-pnpm- + cache: pnpm - name: Install (immutable) run: pnpm install --frozen-lockfile From a8a1ada183e8c382ef19ceb874e8ba25aeadcd9a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:10:38 +0800 Subject: [PATCH 31/70] docs: move execa Agent Note to implemented; update inbound links and README contracts - proposed/testing -> implemented/testing with the lifecycle rewrite (Proposal->Decision in present tense, Acceptance criteria + Risks folded into Consequences); zh counterpart mirrored and both pairs re-recorded. - the rejected NIH-audit roll-up pair now links the implemented/ path. - loader-smoke README: captured output is bounded by execa's default 100 MB maxBuffer, no longer unbounded. - acp-snapshot README: harness.ts now also imports vitest (vi.waitFor), so the vitest-run-only constraint names both modules. - jsonrpc keyless smoke: raise the invalid-env case's subprocess deadline to 25s (the 9s pick starved a cold tsx boot on slow NFS). --- ...eca-for-test-subprocess-plumbing.i18n.yaml | 4 +- ...7-26-execa-for-test-subprocess-plumbing.md | 37 +++++++++++++++++ ...6-execa-for-test-subprocess-plumbing.zh.md | 37 +++++++++++++++++ ...7-26-execa-for-test-subprocess-plumbing.md | 41 ------------------- ...6-execa-for-test-subprocess-plumbing.zh.md | 41 ------------------- ...ency-swaps-rejected-by-nih-audit.i18n.yaml | 4 +- ...-dependency-swaps-rejected-by-nih-audit.md | 2 +- ...pendency-swaps-rejected-by-nih-audit.zh.md | 2 +- .../jsonrpc-agent/tests/keyless-smoke.e2e.ts | 4 +- .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- .../support/loader-smoke/README.i18n.yaml | 4 +- packages/support/loader-smoke/README.md | 2 +- packages/support/loader-smoke/README.zh.md | 2 +- 15 files changed, 90 insertions(+), 98 deletions(-) rename .agents/notes/{proposed => implemented}/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml (61%) create mode 100644 .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md create mode 100644 .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md delete mode 100644 .agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md delete mode 100644 .agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md diff --git a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml similarity index 61% rename from .agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml rename to .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml index 90cad79b89..606229b494 100644 --- a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml @@ -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 -2026-07-26-execa-for-test-subprocess-plumbing.md: 99a86258fe4d59db6a0e144dbcee94c095f70f8f -2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 525e09f07ce3e5dc61f1cadab5c11ea0790cccee +2026-07-26-execa-for-test-subprocess-plumbing.md: a25010b1cab7012cf9c659cfd8272d17e33618c5 +2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 733c9f7e7f666052f030ed3b0f916e4832aaa120 diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md new file mode 100644 index 0000000000..a25010b1ca --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md @@ -0,0 +1,37 @@ +# Agent Note: Adopt execa for hand-rolled test subprocess plumbing + +Status: implemented + +English | [中文](2026-07-26-execa-for-test-subprocess-plumbing.zh.md) + +## Problem + +Roughly ten e2e/smoke files re-derived the same spawn-collect-timeout choreography by hand: `let stdout = ''` accumulation with `setEncoding` and `data` handlers, a `setTimeout` → `kill('SIGKILL')` deadline, and `once('exit')`/`once('error')` settlement, each with small variations. The sites: the inner spawn block of `runLoaderSmoke` (`packages/support/loader-smoke/src/index.ts`), `runBuiltBin` in `apps/cli/tests/built-bin.e2e.ts` and `packages/examples/cli-demo/tests/built-bin.e2e.ts`, `runBinExpectingExit` in `packages/examples/acp-demo/tests/built-bin.e2e.ts`, the built-lib e2e helpers in `lsp-local` and `code-runtime-worker`, the outer collector of `examples/tui-agent/tests/pty-harness.ts`, `examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`, and partially `apps/web/tests/smoke-real.e2e.ts` and `session-checkpoint-policy/tests/crash-recovery.e2e.ts`. + +Two related test-infra hand-rolls compounded the case: + +- `packages/support/llm-mock-server/src/cli.ts` hand-tokenized 17 value-taking `--flag value` options plus boolean flags (~45–60 lines of loop and value-extraction helpers) where the `node:util` `parseArgs` builtin is already the repo idiom (`cli-demo`, `acp-demo`, `verify-runtime-closure.ts`, `packages/sdk/scripts`). +- `apps/web/tests/smoke-real.e2e.ts` and `apps/web/tests/scaffold.ts` carried two verbatim copies of a regex `.env` parser (~20 lines) where the `process.loadEnvFile` builtin has exactly the required no-override semantics — and the vitest e2e/snapshot/web configs already load root `.env` with it before these files run, making the copies dead. +- The snapshot harness hand-rolled three poll-until-deadline loops (`waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile` in `packages/support/acp-snapshot/src/harness.ts`, ~55 lines) plus `waitForFile` in `crash-recovery.e2e.ts`, where `vi.waitFor`/`expect.poll` cover the shape — vitest is already a runtime dependency of `dsh-acp-snapshot`, so this adds nothing. + +## Decision + +- `execa` is a root devDependency and a runtime dependency of `@deepseek-ai/dsh-loader-smoke` (the one `src/` consumer). The listed spawn-collect-timeout sites run through `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })`, whose result reports `{ stdout, stderr, exitCode, signal, timedOut, failed }` as independent fields — matching the repo's own defensive-patterns rule to report orthogonal subprocess outcomes independently. `runLoaderSmoke` passes `input: ''` for its stdin-close contract, and sites whose assertions pin exact stream bytes pass `stripFinalNewline: false`. +- The genuinely custom parts stay custom on top of an execa-owned subprocess: cli-demo's interrupt-on-marker mid-stream logic, jsonrpc's line-predicate protocol driving, and crash-recovery's SIGKILL-at-failpoint choreography. `smoke-real.e2e.ts` keeps raw `spawn` for its three long-lived interactive servers — ready-line watching across both streams plus a staged SIGTERM→await→SIGKILL teardown are the whole site, so execa would delete nothing there; its share of this note is the dead `.env` parser. +- `llm-mock-server`'s CLI tokenizes via `parseArgs` (strict, no positionals); numeric coercion, bounds, and cross-option constraints stay manual, and the pinned error-message tests carry `parseArgs`'s own tokenizer texts. +- Both `loadRootEnv` copies are deleted outright: the owning vitest configs (`vitest.web.config.ts` unconditionally, `vitest.snapshot.config.ts` in record mode) load the repo-root `.env` before those files run. +- The four poll loops ride `vi.waitFor` with explicit `{ interval, timeout }` and descriptive errors thrown from the callback; `waitForPersistedTurnStart` captures its malformed-record validation error out of the retry loop so it fails the run immediately instead of being retried until the deadline. + +## Alternatives considered + +- **`tinyexec` instead of execa.** Already in `node_modules` transitively via vitest, smaller API — but no kill-escalation, no rich error output embedding, and being transitive is not a contract; if the lighter package is preferred the swap shape is identical. +- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn Windows behavior (taskkill, exit codes) that execa already carries. +- **`get-port`, `wait-on`, `tempy`, `tree-kill`.** Rejected individually: the repo's single port probe is break-even, the file waits are dominated by `vi.waitFor`, temp-dir handling already uses `mkdtemp` + `rm {recursive}` builtins everywhere, and acp-snapshot's `close()` is drain-ordering logic, not tree traversal. + +## Consequences + +- The hand-rolled collect/timeout blocks are gone, including the two `/* v8 ignore */` un-inducible OS-error branches in `loader-smoke`: spawn and stream failures settle through execa's result fields, so the `src/` file carries no coverage exemptions and the per-file gate covers every remaining branch. +- Captured output is bounded by execa's default 100 MB `maxBuffer` (overflow terminates the subprocess) where it was previously unbounded; the `loader-smoke` README's limitation entry reflects this. +- Windows termination behavior (taskkill, exit-code mapping) is owned by execa instead of per-site hand-rolls; each rewritten suite was re-run on POSIX in this change, and the Windows CI lanes own the other platform. +- execa is a new root devDependency (previously absent from the lockfile); it is one of the most-depended-on packages on npm and actively maintained, and the exe/runtime closure is unaffected (tests only). +- The mock-server CLI's tokenizer-level error texts are no longer this repo's to choose: unknown options, missing values, and stray positionals report `parseArgs`'s wording, pinned as such in `tests/cli.spec.ts`. diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md new file mode 100644 index 0000000000..733c9f7e7f --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 采用 execa 替换手写的测试子进程管道代码 + +Status: implemented + +[English](2026-07-26-execa-for-test-subprocess-plumbing.md) | 中文 + +## 问题 + +大约十个 e2e/冒烟测试文件各自手工重写过同一套「spawn、收集输出、超时终止」编排:用 `setEncoding` 加 `data` 处理器做 `let stdout = ''` 式累积,用 `setTimeout` → `kill('SIGKILL')` 设定超时截止,再以 `once('exit')`/`once('error')` 结算结果,各处只有细微差别。这些位置是:`runLoaderSmoke` 的内层 spawn 代码块(`packages/support/loader-smoke/src/index.ts`)、`apps/cli/tests/built-bin.e2e.ts` 与 `packages/examples/cli-demo/tests/built-bin.e2e.ts` 中的 `runBuiltBin`、`packages/examples/acp-demo/tests/built-bin.e2e.ts` 中的 `runBinExpectingExit`、`lsp-local` 与 `code-runtime-worker` 中基于构建产物的 e2e 辅助函数、`examples/tui-agent/tests/pty-harness.ts` 的外层收集器、`examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`,以及部分涉及的 `apps/web/tests/smoke-real.e2e.ts` 和 `session-checkpoint-policy/tests/crash-recovery.e2e.ts`。 + +另有两处相关的测试基础设施手写代码进一步强化了替换的理由: + +- `packages/support/llm-mock-server/src/cli.ts` 曾手工逐个切分 17 个带值的 `--flag value` 选项外加若干布尔标志(约 45–60 行的循环与取值辅助函数),而 `node:util` 内置的 `parseArgs` 早已是本仓库的惯用写法(`cli-demo`、`acp-demo`、`verify-runtime-closure.ts`、`packages/sdk/scripts`)。 +- `apps/web/tests/smoke-real.e2e.ts` 与 `apps/web/tests/scaffold.ts` 曾携带两份逐字相同的正则 `.env` 解析器拷贝(约 20 行),而内置的 `process.loadEnvFile` 恰好具备所需的「不覆盖已有值」语义;并且 vitest 的 e2e/snapshot/web 配置在这些文件运行之前就已用它加载了根 `.env`,这两份拷贝实为死代码。 +- 快照 harness 曾手写三个「轮询直到截止时间」的循环(`packages/support/acp-snapshot/src/harness.ts` 中的 `waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile`,约 55 行),外加 `crash-recovery.e2e.ts` 中的 `waitForFile`,而 `vi.waitFor`/`expect.poll` 正好覆盖这种形态;vitest 本来就是 `dsh-acp-snapshot` 的运行时依赖,因此这不新增任何东西。 + +## 决定 + +- `execa` 是根 devDependency,同时是 `@deepseek-ai/dsh-loader-smoke`(唯一的 `src/` 消费者)的运行时依赖。上述 spawn、收集、超时的代码位置统一经由 `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })` 运行:其结果以相互独立的字段报告 `{ stdout, stderr, exitCode, signal, timedOut, failed }`,与本仓库防御模式中「正交的子进程结果各自独立上报」的规则一致。`runLoaderSmoke` 传 `input: ''` 以兑现其 stdin 关闭契约;断言固定精确流字节的位置传 `stripFinalNewline: false`。 +- 真正定制的部分继续保持定制,只是架在 execa 拥有的子进程之上:cli-demo 在流中遇到标记即中断的逻辑、jsonrpc 基于行谓词的协议驱动,以及 crash-recovery 在故障点发送 SIGKILL 的编排。`smoke-real.e2e.ts` 的三个长驻交互式服务器保留原生 `spawn`——跨双流监听就绪行加上分级的 SIGTERM→等待→SIGKILL 拆除就是该处的全部内容,execa 在那里删不掉任何东西;它在本 note 中的份额是那份死的 `.env` 解析器。 +- `llm-mock-server` 的 CLI 经由 `parseArgs` 切分(strict、不允许位置参数);数值转换、边界检查与跨选项约束仍手工实现,被固定的错误消息测试改为携带 `parseArgs` 自己的切分器文本。 +- 两份 `loadRootEnv` 拷贝被整体删除:拥有它们的 vitest 配置(`vitest.web.config.ts` 无条件、`vitest.snapshot.config.ts` 在 record 模式下)在这些文件运行之前就加载了仓库根部的 `.env`。 +- 那四个轮询循环改乘 `vi.waitFor`,显式传入 `{ interval, timeout }`,并在回调中抛出带描述信息的错误;`waitForPersistedTurnStart` 把「持久化记录格式非法」的校验错误捕获到重试循环之外,使其立即让运行失败,而不是被重试到截止时间。 + +## 曾考虑的替代方案 + +- **用 `tinyexec` 代替 execa。**它已经作为 vitest 的传递依赖存在于 `node_modules` 中,API 也更小;但它没有终止信号逐级升级,不会把丰富的输出嵌入错误对象,而且传递依赖并不构成契约。如果最终更倾向这个更轻的包,替换的形态完全相同。 +- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的 Windows 行为(taskkill、退出码)。 +- **`get-port`、`wait-on`、`tempy`、`tree-kill`。**逐一不予采纳:仓库仅有的一处端口探测替换后收支相抵;文件等待场景已由 `vi.waitFor` 更优地覆盖;临时目录处理在各处已经使用内置的 `mkdtemp` + `rm {recursive}`;acp-snapshot 的 `close()` 是排空顺序逻辑,不是进程树遍历。 + +## 后果 + +- 手写的收集/超时代码块全部移除,包括 `loader-smoke` 中两个标注 `/* v8 ignore */`、无法人为诱发的 OS 错误分支:spawn 与流故障如今经由 execa 的结果字段结算,这个 `src/` 文件不再携带任何覆盖率豁免,逐文件门禁覆盖其余全部分支。 +- 捕获的输出如今受 execa 默认 100 MB `maxBuffer` 约束(溢出即终止子进程),此前是无界的;`loader-smoke` README 的局限条目反映了这一点。 +- Windows 终止行为(taskkill、退出码映射)由 execa 拥有,不再逐处手写;每个改写后的套件在本次变更中已在 POSIX 上重新运行,另一平台由 Windows CI 车道负责。 +- execa 是新增的根 devDependency(此前完全不存在于 lockfile 中);它是 npm 上被依赖最多的包之一且维护活跃,exe/运行时闭包不受影响(仅测试使用)。 +- mock-server CLI 切分器层面的错误文本不再由本仓库决定:未知选项、缺失取值与多余位置参数报告 `parseArgs` 的措辞,并在 `tests/cli.spec.ts` 中如此固定。 diff --git a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md b/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md deleted file mode 100644 index 99a86258fe..0000000000 --- a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: Adopt execa for hand-rolled test subprocess plumbing - -Status: proposed - -English | [中文](2026-07-26-execa-for-test-subprocess-plumbing.zh.md) - -## Problem - -Roughly ten e2e/smoke files re-derive the same spawn-collect-timeout choreography by hand: `let stdout = ''` accumulation with `setEncoding` and `data` handlers, a `setTimeout` → `kill('SIGKILL')` deadline, and `once('exit')`/`once('error')` settlement, each with small variations. The sites: the inner spawn block of `runLoaderSmoke` (`packages/support/loader-smoke/src/index.ts`), `runBuiltBin` in `apps/cli/tests/built-bin.e2e.ts` and `packages/examples/cli-demo/tests/built-bin.e2e.ts`, `runBinExpectingExit` in `packages/examples/acp-demo/tests/built-bin.e2e.ts`, the built-lib e2e helpers in `lsp-local` and `code-runtime-worker`, the outer collector of `examples/tui-agent/tests/pty-harness.ts`, `examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`, and partially `apps/web/tests/smoke-real.e2e.ts` and `session-checkpoint-policy/tests/crash-recovery.e2e.ts`. Net deletable: ~100–150 lines of test infrastructure. - -Two related test-infra hand-rolls compound the case: - -- `packages/support/llm-mock-server/src/cli.ts` hand-tokenizes 17 value-taking `--flag value` options plus boolean flags (~45–60 lines of loop and value-extraction helpers) where the `node:util` `parseArgs` builtin is already the repo idiom (`cli-demo`, `acp-demo`, `verify-runtime-closure.ts`, `packages/sdk/scripts`). -- `apps/web/tests/smoke-real.e2e.ts` and `apps/web/tests/scaffold.ts` carry two verbatim copies of a regex `.env` parser (~20 lines) where the `process.loadEnvFile` builtin has exactly the required no-override semantics — and the vitest e2e/snapshot/web configs already load root `.env` with it before these files run, making the copies arguably dead. -- The snapshot harness hand-rolls three poll-until-deadline loops (`waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile` in `packages/support/acp-snapshot/src/harness.ts`, ~55 lines) plus `waitForFile` in `crash-recovery.e2e.ts`, where `vi.waitFor`/`expect.poll` cover the shape — vitest is already a runtime dependency of `dsh-acp-snapshot`, so this adds nothing. - -## Proposal - -- Add `execa` as a root devDependency and rewrite the spawn-collect-timeout sites onto `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })`, whose result reports `{ stdout, stderr, exitCode, signal, timedOut }` as independent fields — matching the repo's own defensive-patterns rule to report orthogonal subprocess outcomes independently. Keep the genuinely custom parts custom: cli-demo's interrupt-on-marker mid-stream logic, jsonrpc's line-predicate protocol driving, and crash-recovery's SIGKILL-at-failpoint choreography. -- Swap `llm-mock-server`'s CLI tokenizer for `parseArgs` (numeric coercion, bounds, and cross-option constraints stay manual; pinned error-message texts update with the tests). -- Delete both `loadRootEnv` copies in favor of `process.loadEnvFile` in a try/catch, or remove them outright if the vitest-config loading already covers them. -- Replace the four poll loops with `vi.waitFor`/`expect.poll`, passing explicit `{ interval, timeout }` and throwing descriptive errors from the callback. - -## Alternatives considered - -- **`tinyexec` instead of execa.** Already in `node_modules` transitively via vitest, smaller API — but no kill-escalation, no rich error output embedding, and being transitive is not a contract; if the lighter package is preferred the swap shape is identical. -- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn Windows behavior (taskkill, exit codes) that execa already carries. -- **`get-port`, `wait-on`, `tempy`, `tree-kill`.** Rejected individually: the repo's single port probe is break-even, the file waits are dominated by `vi.waitFor`, temp-dir handling already uses `mkdtemp` + `rm {recursive}` builtins everywhere, and acp-snapshot's `close()` is drain-ordering logic, not tree traversal. - -## Acceptance criteria - -- The listed sites spawn through execa (or the chosen equivalent); the hand-rolled collect/timeout blocks and the two `/* v8 ignore */` un-inducible OS-error branches in `loader-smoke` are gone. -- `llm-mock-server` CLI parses via `parseArgs`; its cli spec passes with updated message expectations. -- No hand-rolled `.env` parser remains under `apps/web/tests`. -- The affected e2e and snapshot suites pass on both POSIX and Windows CI lanes. - -## Risks - -- `loader-smoke` is a `src/` file under the per-file-100% coverage gate; the swap actually simplifies its coverage story (removes un-inducible branches) but the new call shape needs coverage. -- Each rewritten e2e must be re-run on both platforms; subtle differences in kill escalation or stdin-close semantics (`input: ''` for loader-smoke's stdin-close contract) are the risk to verify per site. -- execa is a new root devDependency (currently absent from the lockfile entirely); it is one of the most-depended-on packages on npm and actively maintained, so health is not a concern, but the exe/runtime closure is unaffected either way (tests only). diff --git a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md b/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md deleted file mode 100644 index 525e09f07c..0000000000 --- a/.agents/notes/proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: 采用 execa 替换手写的测试子进程管道代码 - -Status: proposed - -[English](2026-07-26-execa-for-test-subprocess-plumbing.md) | 中文 - -## 问题 - -大约十个 e2e/冒烟测试文件各自手工重写同一套「spawn、收集输出、超时终止」编排:用 `setEncoding` 加 `data` 处理器做 `let stdout = ''` 式累积,用 `setTimeout` → `kill('SIGKILL')` 设定超时截止,再以 `once('exit')`/`once('error')` 结算结果,各处只有细微差别。这些位置是:`runLoaderSmoke` 的内层 spawn 代码块(`packages/support/loader-smoke/src/index.ts`)、`apps/cli/tests/built-bin.e2e.ts` 与 `packages/examples/cli-demo/tests/built-bin.e2e.ts` 中的 `runBuiltBin`、`packages/examples/acp-demo/tests/built-bin.e2e.ts` 中的 `runBinExpectingExit`、`lsp-local` 与 `code-runtime-worker` 中基于构建产物的 e2e 辅助函数、`examples/tui-agent/tests/pty-harness.ts` 的外层收集器、`examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts`,以及部分涉及的 `apps/web/tests/smoke-real.e2e.ts` 和 `session-checkpoint-policy/tests/crash-recovery.e2e.ts`。净可删除量:约 100–150 行测试基础设施代码。 - -另有两处相关的测试基础设施手写代码进一步强化了替换的理由: - -- `packages/support/llm-mock-server/src/cli.ts` 手工逐个切分 17 个带值的 `--flag value` 选项外加若干布尔标志(约 45–60 行的循环与取值辅助函数),而 `node:util` 内置的 `parseArgs` 早已是本仓库的惯用写法(`cli-demo`、`acp-demo`、`verify-runtime-closure.ts`、`packages/sdk/scripts`)。 -- `apps/web/tests/smoke-real.e2e.ts` 与 `apps/web/tests/scaffold.ts` 携带两份逐字相同的正则 `.env` 解析器拷贝(约 20 行),而内置的 `process.loadEnvFile` 恰好具备所需的「不覆盖已有值」语义;并且 vitest 的 e2e/snapshot/web 配置在这些文件运行之前就已用它加载了根 `.env`,这两份拷贝几乎可以视为死代码。 -- 快照 harness 手写了三个「轮询直到截止时间」的循环(`packages/support/acp-snapshot/src/harness.ts` 中的 `waitForPersistedTurnStart`/`waitForPersistedTurnEnd`/`waitForWorkspaceFile`,约 55 行),外加 `crash-recovery.e2e.ts` 中的 `waitForFile`,而 `vi.waitFor`/`expect.poll` 正好覆盖这种形态;vitest 本来就是 `dsh-acp-snapshot` 的运行时依赖,因此这不新增任何东西。 - -## 提案 - -- 将 `execa` 添加为根 devDependency,把上述 spawn、收集、超时的代码位置改写到 `await execa(cmd, args, { cwd, env, timeout, killSignal: 'SIGKILL', reject: false })` 上:其结果以相互独立的字段报告 `{ stdout, stderr, exitCode, signal, timedOut }`,与本仓库防御模式中「正交的子进程结果各自独立上报」的规则一致。真正定制的部分继续保持定制:cli-demo 在流中遇到标记即中断的逻辑、jsonrpc 基于行谓词的协议驱动,以及 crash-recovery 在故障点发送 SIGKILL 的编排。 -- 把 `llm-mock-server` 的 CLI 切分器换成 `parseArgs`(数值转换、边界检查与跨选项约束仍手工实现;被固定的错误消息文本随测试一并更新)。 -- 删除两份 `loadRootEnv` 拷贝,改用包在 try/catch 中的 `process.loadEnvFile`;如果 vitest 配置的加载已经覆盖了它们,则直接整体移除。 -- 用 `vi.waitFor`/`expect.poll` 替换那四个轮询循环,显式传入 `{ interval, timeout }`,并在回调中抛出带描述信息的错误。 - -## 曾考虑的替代方案 - -- **用 `tinyexec` 代替 execa。**它已经作为 vitest 的传递依赖存在于 `node_modules` 中,API 也更小;但它没有终止信号逐级升级,不会把丰富的输出嵌入错误对象,而且传递依赖并不构成契约。如果最终更倾向这个更轻的包,替换的形态完全相同。 -- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的 Windows 行为(taskkill、退出码)。 -- **`get-port`、`wait-on`、`tempy`、`tree-kill`。**逐一不予采纳:仓库仅有的一处端口探测替换后收支相抵;文件等待场景已由 `vi.waitFor` 更优地覆盖;临时目录处理在各处已经使用内置的 `mkdtemp` + `rm {recursive}`;acp-snapshot 的 `close()` 是排空顺序逻辑,不是进程树遍历。 - -## 验收标准 - -- 所列位置全部通过 execa(或最终选定的等价包)spawn 子进程;手写的收集/超时代码块,连同 `loader-smoke` 中两个标注 `/* v8 ignore */`、无法人为诱发的 OS 错误分支,全部移除。 -- `llm-mock-server` 的 CLI 经由 `parseArgs` 解析;其 cli 测试文件在更新消息期望后通过。 -- `apps/web/tests` 下不再存在手写的 `.env` 解析器。 -- 受影响的 e2e 与快照测试套件在 POSIX 与 Windows 两条 CI 车道上均通过。 - -## 风险 - -- `loader-smoke` 是逐文件 100% 覆盖率门禁下的 `src/` 文件;这次替换实际上简化了它的覆盖率问题(移除了无法人为诱发的分支),但新的调用形态需要补齐覆盖。 -- 每个改写后的 e2e 都必须在两个平台上重新运行;终止信号升级或 stdin 关闭语义上的细微差异(loader-smoke 的 stdin 关闭契约对应 `input: ''`)是需要逐处核验的风险。 -- execa 是新增的根 devDependency(当前完全不存在于 lockfile 中);它是 npm 上被依赖最多的包之一且维护活跃,健康度不是顾虑;至于 exe/运行时闭包,无论选哪个包都不受影响(仅测试使用)。 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml index 8749dbd0bc..e217158aa6 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml @@ -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 -2026-07-26-dependency-swaps-rejected-by-nih-audit.md: c988ca0c75e9c50686551f3be1971d736b971e2a -2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: e85161cb2ee616d388aa2a9dd065c315c60cd44a +2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 31c925cd7bfe21e2020ae8bd3ba8f9e2b0398641 +2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 097ba6c879a9eab7ae25f9a3020c403380842014 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md index c988ca0c75..31c925cd7b 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md @@ -46,7 +46,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`shell-quote` for POSIX single-quoting**: two 1-line quoting helpers with exhaustive tests versus a maintenance-mode package with a CVE history and different escaping output — a safety boundary is the wrong place to save one line. - **`strip-ansi` for pty sanitization**: the pty sanitizer is a streaming state machine with split-sequence carry across chunks and OSC `133;D` prompt-marker extraction (the shell-readiness signal); stateless strippers replace ~20 inner lines while all state machinery stays. `stripVTControlCharacters` also demonstrably leaks unterminated-OSC payloads the session-title normalizer must strip (anti-spoofing). - **`pidtree`/`ps-tree` for the pty process inspector**: bare PID trees; the code needs start-time identity against PID reuse plus `/proc` stdin-wait detection no package does. -- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [execa proposal](../../proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).) +- **`execa` for the subagent-subprocess dispose ladder**: `forceKillAfterDelay` covers SIGTERM→SIGKILL but not the stdin-EOF-first cooperative tier or the reject-if-no-exit-edge contract; adopting it here rewrites spawn sites while keeping the ladder. (Test-infrastructure spawn plumbing is different — see the [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md).) - **`tree-kill` for acp-snapshot teardown and lsp process kill**: the lines are drain-ordering/error-propagation, not tree traversal; lsp/bash already use detached process groups + taskkill. - **node-pty everywhere for the TUI test driver**: [Windows-TUI note](../../implemented/feature/2026-07-20-windows-tui-support.md) explicitly rejected node-pty-on-every-host; it is already the Windows leg. diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md index e85161cb2e..097ba6c879 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md @@ -46,7 +46,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `shell-quote` 承担 POSIX 单引号包裹**:两个各 1 行、测试详尽的引号辅助函数,对上一个处于维护模式、有 CVE 历史、转义输出还不一样的包——安全边界不是省一行代码的地方。 - **以 `strip-ansi` 承担 pty 净化**:pty 净化器是一台流式状态机,带跨分片的断裂序列续接和 OSC `133;D` 提示符标记提取(shell 就绪信号);无状态的剥离器只能替掉约 20 行内层代码,全部状态机构件原样保留。`stripVTControlCharacters` 还被实证会泄漏未终止的 OSC 载荷,会话标题归一化器必须剥除它们(反欺骗)。 - **以 `pidtree`/`ps-tree` 承担 pty 进程巡检器**:它们只给裸 PID 树;这段代码需要对抗 PID 复用的启动时间身份校验,加上 `/proc` stdin 等待检测,没有包做这些。 -- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见 [execa 提案](../../proposed/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。) +- **以 `execa` 承担 subagent-subprocess 的 dispose(资源释放)阶梯**:`forceKillAfterDelay` 覆盖 SIGTERM→SIGKILL,但覆盖不了先发 stdin EOF 的协作层级,也覆盖不了「无退出沿即 reject」契约;在这里采用它意味着重写各 spawn 调用点、同时阶梯照旧保留。(测试基础设施的 spawn 管线是另一回事——见 [execa Agent Note](../../implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md)。) - **以 `tree-kill` 承担 acp-snapshot 拆除与 lsp 进程终止**:那些代码行做的是排空顺序与错误传播,不是进程树遍历;lsp/bash 已经使用分离的进程组加 taskkill。 - **在 TUI 测试驱动器上到处使用 node-pty**:[Windows TUI 决策](../../implemented/feature/2026-07-20-windows-tui-support.md)已明确否决在每个宿主上都用 node-pty;它已经是 Windows 那一条腿。 diff --git a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts index fb31afd033..0d4e4d8f2e 100644 --- a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts +++ b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts @@ -176,7 +176,7 @@ describe('jsonrpc-agent keyless smoke', () => { DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes', }, stdin: 'ignore', - timeout: 9_000, + timeout: 25_000, killSignal: 'SIGKILL', reject: false, }) @@ -184,5 +184,5 @@ describe('jsonrpc-agent keyless smoke', () => { expect(exitCode, stderr).toBe(1) expect(stdout).toBe('') expect(stderr).toContain('plugin(s) failed to load: @deepseek-ai/dsh-jsonrpc') - }, 10_000) + }, 30_000) }) diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index fd0fe03cb8..d706584b02 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -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 -README.md: f3817a386a286e1dca40334fed7cb169643cb7e4 -README.zh.md: 2f87e9ef7b29f65f81f8b464f59725c13a057003 +README.md: 8babb67c30aed87ace4cfff81b2494a03a5b0335 +README.zh.md: 3f43627740054e200e928ffe527f827c710599b0 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index f3817a386a..8babb67c30 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -55,7 +55,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). -Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. +Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. ## Model Experience diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 2f87e9ef7b..3f43627740 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -55,7 +55,7 @@ defineAcpSnapshotSuite({ 示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及每个 pin 的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 -约束:`suite.ts` 导入 vitest,因此包入口只能在 vitest 运行中导入(启动器、harness 和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 +约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 ## 模型体验 diff --git a/packages/support/loader-smoke/README.i18n.yaml b/packages/support/loader-smoke/README.i18n.yaml index a4e0016620..4794ac72cb 100644 --- a/packages/support/loader-smoke/README.i18n.yaml +++ b/packages/support/loader-smoke/README.i18n.yaml @@ -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 -README.md: 8e53550608037a3c9a272db825933b7224ab24db -README.zh.md: 5310429ab59cf3cd04ac024746f5ed557e003637 +README.md: 73610ce50ebac4c6fc7bb9135f7b41b347c60685 +README.zh.md: 17f8481220136e8edf9fccd23fabfca5ccf41dfc diff --git a/packages/support/loader-smoke/README.md b/packages/support/loader-smoke/README.md index 8e53550608..73610ce50e 100644 --- a/packages/support/loader-smoke/README.md +++ b/packages/support/loader-smoke/README.md @@ -19,5 +19,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Built mode requires a prior build** — the config must also resolve every named package upward through `examples/node_modules`. -- **Captured stdout and stderr are unbounded** — a runaway child can consume memory until the deadline kills it. +- **Captured stdout and stderr are bounded only by execa's default 100 MB `maxBuffer`** — a runaway child is terminated at that ceiling rather than at a smoke-chosen budget. - **Timeout kills only the direct child** — a process tree spawned by a faulty fixture can outlive the smoke and needs external cleanup. diff --git a/packages/support/loader-smoke/README.zh.md b/packages/support/loader-smoke/README.zh.md index 5310429ab5..17f8481220 100644 --- a/packages/support/loader-smoke/README.zh.md +++ b/packages/support/loader-smoke/README.zh.md @@ -19,5 +19,5 @@ ## 已知限制与待完成工作 - **构建 mode 需要事先构建**:配置还必须能够通过 `examples/node_modules` 向上解析每个命名包。 -- **捕获的 stdout 和 stderr 无界**:失控子进程可以消耗内存,直到 deadline 将其终止。 +- **捕获的 stdout 和 stderr 仅受 execa 默认 100 MB `maxBuffer` 约束**:失控子进程会在该上限处被终止,而不是在冒烟测试自选的预算处。 - **超时只终止直接子进程**:故障 fixture 生成的进程树可以比冒烟测试存活更久,需要外部清理。 From 3cabde323f5e299454bf8179dbbfbf51aa5ef039 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:45:13 +0800 Subject: [PATCH 32/70] fix(acp-snapshot): keep the malformed-record capture branch-free for per-file coverage Store the captured validation error as unknown in a wrapper object and rethrow it directly: the instanceof-Error normalization added an un-inducible false branch that failed harness.ts's 100% branch gate. --- packages/support/acp-snapshot/src/harness.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 0cf1cde9ab..ff98e620d3 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -457,7 +457,7 @@ async function waitForPersistedTurnStart( timeoutMs = DEFAULT_WAIT_TIMEOUT_MS, minimumTurn?: number, ): Promise { - let invalidRecord: Error | undefined + let invalidRecord: { error: unknown } | undefined await vi.waitFor(async () => { const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId) let openTurn: number | undefined @@ -467,7 +467,7 @@ async function waitForPersistedTurnStart( // A malformed persisted record is a scenario bug, not a not-yet state: // vi.waitFor retries every callback throw, so capture the validation // failure, resolve the wait, and rethrow immediately below. - invalidRecord = error instanceof Error ? error : new Error(String(error)) + invalidRecord = { error } return } if (openTurn === undefined || (minimumTurn !== undefined && openTurn < minimumTurn)) { @@ -475,7 +475,7 @@ async function waitForPersistedTurnStart( throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${detail} within ${timeoutMs}ms`) } }, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs }) - if (invalidRecord !== undefined) throw invalidRecord + if (invalidRecord !== undefined) throw invalidRecord.error } /** From d3a4f4af1b270e4d1e6dcdc1d139eed6bbe976e7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:18:03 +0800 Subject: [PATCH 33/70] fix(tests): lsp specs ride the seam scrub; acp e2e mounts the subprocess service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI now exports DSH_GATE_CONCURRENCY into every job, and the lsp suites spread the raw parent env into the spawn spec's ordinary channel — which the seam rightly rejects for DSH_* names. The specs now build their child env the way production does (scrubbedParentEnv() + explicit extras). The acp with-key e2e never mounted dsh-subprocess-local, leaving the provider's inject unsatisfied, so no 'acp' provider ever registered. --- packages/lsp/lsp-local/tests/connection.spec.ts | 5 +++-- packages/lsp/lsp-local/tests/instance.spec.ts | 5 +++-- packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts | 3 +++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index cdb331077c..0fdf223521 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { fileURLToPath } from 'node:url' import { LspConnection } from '@deepseek-ai/dsh-lsp-local' import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -29,7 +30,7 @@ function connect( command: process.execPath, args: [fixtureServer], cwd: process.cwd(), - env: { ...process.env as Record, ...env }, + env: { ...scrubbedParentEnv(), ...env }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, pipeDrainGraceMs: 3_000, @@ -139,7 +140,7 @@ function connectScript(script: string, maxStderrBytes = 100_000, writer?: Connec command: process.execPath, args: ['-e', script], cwd: process.cwd(), - env: { ...process.env as Record }, + env: scrubbedParentEnv(), maxMessageBytes: 16_000_000, maxStderrBytes, pipeDrainGraceMs: 3_000, diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 48dbd0252a..7d0ca74d0c 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -9,6 +9,7 @@ import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -39,7 +40,7 @@ function makeInstance( command: process.execPath, args: [fixtureServer], cwd: ws, - env: { ...process.env as Record, ...env }, + env: { ...scrubbedParentEnv(), ...env }, configuration: { setting: 42 }, initializationOptions: { init: true }, maxMessageBytes: 16_000_000, @@ -69,7 +70,7 @@ function scriptInstance(script: string, overrides: Partial = {}): command: process.execPath, args: ['-e', script], cwd: ws, - env: { ...process.env as Record }, + env: scrubbedParentEnv(), configuration: null, initializationOptions: null, maxMessageBytes: 16_000_000, diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index b3909b708d..d361d26f4f 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import * as acp from '../src/index.ts' @@ -52,6 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-')) ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: childLaunch.command, @@ -81,6 +83,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-')) ctx = new Context() await ctx.plugin(SubagentService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(acp, { providerName: 'acp', command: childLaunch.command, From 23e82fdfb04fa4cbc8ac2c9fbe4dd302c10ae3d6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:34:41 +0800 Subject: [PATCH 34/70] docs(process): add the subprocess area to the PR label taxonomy The new capability family is a recurring repository domain; the area list grows to 46 (en+zh, pair re-recorded) and the GitHub label exists. --- .../process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml | 4 ++-- .../process/2026-07-25-semantic-pr-label-taxonomy.md | 4 ++-- .../process/2026-07-25-semantic-pr-label-taxonomy.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml index 8bc3f87435..e8fdb3f515 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml @@ -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 -2026-07-25-semantic-pr-label-taxonomy.md: 61b7a829b8c44836cf9c6d0d8a7df463df309d89 -2026-07-25-semantic-pr-label-taxonomy.zh.md: cc0c5e7a8953bc97de51f90349a0e2542be5b77e +2026-07-25-semantic-pr-label-taxonomy.md: 3217b405e968d4d2c1eba1f1a5a08008b18ba514 +2026-07-25-semantic-pr-label-taxonomy.zh.md: 4cc603daa52bc9e6b0a85e33086a559a21dcc621 diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md index 61b7a829b8..3217b405e9 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md @@ -30,13 +30,13 @@ Areas record semantic repository domains rather than temporary initiatives, owne ### Current areas -The 45 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. +The 46 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. | Group | Areas | |---|---| | Agent and model | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | | Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | -| Capabilities | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| Capabilities | `bash`, `subprocess`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | | Interfaces | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | | Repository and release | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md index cc0c5e7a89..4cc603daa5 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md @@ -30,13 +30,13 @@ PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更 ### 当前领域 -当前的 45 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 +当前的 46 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 | 分组 | 领域 | |---|---| | agent(智能体)与模型 | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | | 编排 | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | -| 能力 | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| 能力 | `bash`, `subprocess`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | | 接口 | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | | 仓库与发布 | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | From f5204796639b7b3b8bf22c67148690f0ee7bd716 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:55:32 +0800 Subject: [PATCH 35/70] docs: reject the landstrip evaluation for the win32 sandbox rung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User verdict: landstrip is not battle-tested — a days-old, single-maintainer project (~48 GitHub stars at rejection), which a security-invariant dependency cannot be. The note moves proposed/feature -> rejected/feature with the verdict on the Status line; the sandbox note's deferred-phases cross-link now records the rejection instead of instructing an evaluation, and the NIH roll-up's pointer follows. Supersedes this branch's earlier cross-link commit. --- .../notes/implemented/feature/2026-07-06-sandbox.i18n.yaml | 4 ++-- .agents/notes/implemented/feature/2026-07-06-sandbox.md | 2 +- .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md | 2 +- ...7-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml | 4 ++-- .../2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md | 2 +- ...26-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md | 2 +- ...026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml | 4 ++-- .../2026-07-26-dependency-swaps-rejected-by-nih-audit.md | 2 +- .../2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) rename .agents/notes/{proposed => rejected}/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml (58%) rename .agents/notes/{proposed => rejected}/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md (93%) rename .agents/notes/{proposed => rejected}/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md (93%) diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index fdb8e71d3b..ee6efc69f4 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -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 -2026-07-06-sandbox.md: a9af53adfdabc6919113d8c6cb00c0b5f9e58c1f -2026-07-06-sandbox.zh.md: 6db1a914f1560e17296b7ea28f8c7367ecc58a81 +2026-07-06-sandbox.md: c6883873192f15ba2982436e156d8795396c0148 +2026-07-06-sandbox.zh.md: d84df9b06b15dd296801073d381603f34cfd2878 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index a9af53adfd..c688387319 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -128,7 +128,7 @@ Each phase gets its full design when picked up, validated against the code at th - **Second consumer** — `subagent-acp` optionally confines child agents (per-call policy; unconfined default — a child agent must write its own persistence). - **More environments** — an environment-coherent capability group example (e.g. bash+fs against one container). -- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. Before implementing this rung, complete the [landstrip evaluation gate](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md). +- **Windows chain** — `PLATFORM_CHAINS.win32` is reserved and empty (fail-closed); filling it means a confinement runner from the AppContainer/restricted-token family, shipped from its own repository on the `node-addon-landlock-run` template, plus its profile dialect and denial/runner-failure signatures. Wrapping the third-party landstrip runner instead was [considered and rejected](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — not battle-tested enough for a security invariant. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 6db1a914f1..d84df9b06b 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -128,7 +128,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层 - **第二个消费方**——`subagent-acp` 可选地约束子 agent(按调用策略;默认无约束——子 agent 必须写入自己的持久化)。 - **更多环境**——环境一致的能力组示例(如 bash+fs 对一个容器)。 -- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。在实现该梯级之前,先完成 [landstrip 评估门禁](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)。 +- **Windows 链**——`PLATFORM_CHAINS.win32` 保留为空(失败关闭);填充它意味着来自 AppContainer/restricted-token 家族的约束 runner,从其自己的仓库按 `node-addon-landlock-run` 模板交付,加上其 profile 方言和拒绝/runner 失败签名。改为包装第三方 landstrip runner 的方案[经考虑后已驳回](../../rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——对安全不变式而言,它还远未经过实战检验。 ## 曾考虑的替代方案 diff --git a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml similarity index 58% rename from .agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml index 56e0178e8d..2dc0338121 100644 --- a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml +++ b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml @@ -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 -2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md: 047449f4915c973e86cdb9f05f6dc51535133534 -2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md: 379d57e1e0006bf8f567d0b750ca0bb641ca6b49 +2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md: 236139f9198f178d44cdf0867cbad2377a127359 +2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md: 3932f73a2bf147ce5088b5c42e85982c70cdb945 diff --git a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md similarity index 93% rename from .agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md index 047449f491..236139f919 100644 --- a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md +++ b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md @@ -1,6 +1,6 @@ # Agent Note: Evaluate landstrip before building a Windows sandbox launcher -Status: proposed +Status: rejected — landstrip is not battle-tested (a days-old single-maintainer project, ~48 GitHub stars at rejection); a security-invariant dependency must have proven adoption, so the win32 rung keeps the in-house-launcher plan English | [中文](2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md) diff --git a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md similarity index 93% rename from .agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md index 379d57e1e0..3932f73a2b 100644 --- a/.agents/notes/proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md +++ b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md @@ -1,6 +1,6 @@ # Agent Note: 在构建 Windows 沙箱启动器之前先评估 landstrip -Status: proposed +Status: rejected — landstrip 未经实战检验(问世仅数天、单一维护者、驳回时 GitHub 星标约 48 个);安全不变式级的依赖必须有成熟的采用度,因此 win32 梯级维持自研启动器的原计划 [English](2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) | 中文 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml index 8749dbd0bc..21dcac3dd6 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml @@ -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 -2026-07-26-dependency-swaps-rejected-by-nih-audit.md: c988ca0c75e9c50686551f3be1971d736b971e2a -2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: e85161cb2ee616d388aa2a9dd065c315c60cd44a +2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 92ecfeef2deb7f6cca6e99b4e2de7571bc974548 +2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 76f9c012e8986fc8b527cab263f875983bd72609 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md index c988ca0c75..92ecfeef2d 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md @@ -67,7 +67,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`syncpack`/`manypkg` for `check-workspace-constraints.ts`**: they cover ~20 lines of range alignment; the load-bearing 200+ lines (computed `files` lists, cordis peer=dev pairing, hierarchy shape) are repo policy no generic engine expresses. - **`remark-validate-links` for `verify-md-links.ts`**: the gate rides the repo's shared mdast toolchain; adopting remark-cli adds a second markdown stack to delete one small file. - **`prebuildify`/`node-gyp-build` for the landlock launcher packaging**: inapplicable — those load `.node` addons via dlopen; the launcher ships a standalone exec'd static binary, and per-platform `optionalDependencies` *is* the ecosystem convention for binaries. -- **Replacing the Landlock launcher itself with `@landstrip/landstrip`**: fails the security-invariant test — the launcher is a ~300-line reviewable C file with byte-pinned provenance that already migrated away from a Rust dependency; a single-maintainer LGPL Rust binary set is a larger audit surface with weaker provenance. (The unbuilt Windows rung is a different question — see the [landstrip evaluation proposal](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md).) +- **Replacing the Landlock launcher itself with `@landstrip/landstrip`**: fails the security-invariant test — the launcher is a ~300-line reviewable C file with byte-pinned provenance that already migrated away from a Rust dependency; a single-maintainer LGPL Rust binary set is a larger audit surface with weaker provenance. (The unbuilt Windows rung was weighed separately and also [rejected](../feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md) — landstrip is not battle-tested.) - **`hatch-nodejs-version` for Python release versioning**: roughly LOC-neutral (a custom metadata hook replaces the regex), inverts the recorded decision that the dev sentinel never determines a release version, and puts a single-maintainer build plugin in the release supply chain. - **YAML consolidation (`js-yaml` vs `yaml`)**: the repo carries both parsers, with the `!!js` tag defined three times on js-yaml (vendored include, app-boot, apps/cli) and twice on `yaml` (sdk-telemetry's `ScalarTag`, sdk-helper's comment-preserving Document editing). The direction is forced — js-yaml cannot replace `yaml` (sdk-helper needs the Document API) — but migrating the js-yaml sites cannot retire the library either (the vendored include pins it) and would put two parsers in charge of one dialect that must agree exactly, against the [personal-config note](../../implemented/feature/2026-07-20-dsh-cli-personal-config.md)'s deliberate load-only-copy parity. Deletable: ~20–25 lines of duplicate tag definitions and two `@types/js-yaml` entries. The consolidation moment is a future include sync, not now. diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md index e85161cb2e..76f9c012e8 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md @@ -67,7 +67,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `syncpack`/`manypkg` 替换 `check-workspace-constraints.ts`**:它们只覆盖约 20 行的版本范围对齐;承重的 200+ 行(计算生成的 `files` 列表、cordis peer=dev 配对、层级形状)是仓库政策,没有通用引擎能表达。 - **以 `remark-validate-links` 替换 `verify-md-links.ts`**:该门禁搭载仓库共享的 mdast 工具链;采用 remark-cli 等于为删掉一个小文件而增加第二套 markdown 技术栈。 - **以 `prebuildify`/`node-gyp-build` 承担 landlock 启动器打包**:不适用——那些工具通过 dlopen 加载 `.node` addon;这个启动器交付的是独立 exec 的静态二进制,而按平台划分的 `optionalDependencies` 恰恰*就是*二进制分发的生态惯例。 -- **以 `@landstrip/landstrip` 替换 Landlock 启动器本身**:未通过安全不变式检验——启动器是一个约 300 行、可完整评审、来源逐字节锁定的 C 文件,且早已从一个 Rust 依赖迁移出来;单一维护者的 LGPL Rust 二进制集合是更大的审计面加更弱的来源保障。(尚未构建的 Windows 层级是另一个问题——见 [landstrip 评估提案](../../proposed/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)。) +- **以 `@landstrip/landstrip` 替换 Landlock 启动器本身**:未通过安全不变式检验——启动器是一个约 300 行、可完整评审、来源逐字节锁定的 C 文件,且早已从一个 Rust 依赖迁移出来;单一维护者的 LGPL Rust 二进制集合是更大的审计面加更弱的来源保障。(尚未构建的 Windows 层级经单独权衡后同样被[驳回](../feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md)——landstrip 未经实战检验。) - **以 `hatch-nodejs-version` 承担 Python 发布版本号**:代码行数大致持平(一个自定义 metadata 钩子换掉那个正则),却反转了「dev 哨兵值绝不决定发布版本」这条记录在案的决策,还把一个单一维护者的构建插件放进发布供应链。 - **YAML 归一(`js-yaml` 与 `yaml`)**:仓库同时携带两个解析器,`!!js` 标签在 js-yaml 上定义了三次(vendor 收录的 include、app-boot、apps/cli),在 `yaml` 上定义了两次(sdk-telemetry 的 `ScalarTag`、sdk-helper 的保留注释式 Document 编辑)。方向是被迫的——js-yaml 无法取代 `yaml`(sdk-helper 需要 Document API)——但迁移 js-yaml 各调用点也退休不了这个库(vendor 收录的 include 锁定了它),还会让两个解析器共管一种必须完全一致的方言,违背[个人配置决策](../../implemented/feature/2026-07-20-dsh-cli-personal-config.md)刻意的「仅加载副本」对等性。可删除的:约 20–25 行重复标签定义和两条 `@types/js-yaml` 条目。归一的时机是未来某次 include 同步,不是现在。 From fced51d4ebb35bd105cb10e0d9196d7cc5325b04 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:04:37 +0800 Subject: [PATCH 36/70] fix(subagent): route explicit DSH_* config entries onto the managed env channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The acp run passed config.env wholesale into the spawn spec's ordinary channel, which rejects the reserved DSH_* namespace — a deployment fact like DSH_PERMISSION_MODE (the acp-agent example's own knob, used by the with-key e2e) crashed the spawn. The run now splits DSH_* entries onto dshEnv, where the scrubbed base expects current facts to arrive. New layering test drives the split through the real seam via a MOCK_ECHO_ENV knob on the mock server; README env prose updated (en+zh, re-recorded). --- .../subagent/subagent-acp/README.i18n.yaml | 4 ++-- packages/subagent/subagent-acp/README.md | 2 +- packages/subagent/subagent-acp/README.zh.md | 2 +- packages/subagent/subagent-acp/src/run.ts | 20 ++++++++++++++++--- .../subagent-acp/tests/mock-acp-server.ts | 10 +++++++++- .../subagent-acp/tests/subagent-acp.spec.ts | 15 ++++++++++++++ 6 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 10d4b9a7cb..61f41f5665 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/README.i18n.yaml @@ -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 -README.md: 317517f64f24d8a3ed01ebae08dcfd13668b9029 -README.zh.md: e10f435b13e7cdabb92ebfa5b4a5d0763f2af18f +README.md: 0c21e179bd5ca405057b682786c41c8447a78317 +README.zh.md: b349443cfcda80caa9b06cfa1cf80f6e780a98be diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 317517f64f..0c21e179bd 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -57,7 +57,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th ## Process boundary -The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. +The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives; `DSH_*` entries such as `DSH_PERMISSION_MODE` ride the seam's managed channel, since the scrubbed base reserves that namespace for current deployment facts), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index e10f435b13..b349443cfc 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 ## 进程边界 -子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除名称形似凭据的环境变量,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来),stderr 以 inherit 方式直通父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 +子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除名称形似凭据的环境变量,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来;`DSH_PERMISSION_MODE` 这类 `DSH_*` 条目走该 seam 的受管通道,因为清除后的基底把这一命名空间保留给当前部署事实),stderr 以 inherit 方式直通父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 85f1b9fdde..d2fc52d876 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -25,7 +25,8 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' -import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' +import type { DshEnvironmentKey, SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** Fixed response to child permission requests: reject by default, or select the first allow option. */ export type PermissionPolicy = 'allow' | 'reject' @@ -49,6 +50,9 @@ export interface AcpRunSpec { * `DEEPSEEK_API_KEY`). Merged on top of the subprocess seam's scrubbed * parent env. A value here is forwarded even if its name matches the * credential-scrub pattern (an explicit opt-in for the child's own creds). + * Explicit `DSH_*` entries are deployment-owned facts for the child harness + * (e.g. `DSH_PERMISSION_MODE`) and ride the seam's managed channel, which + * the scrubbed base reserves for current values. */ env: Record /** @@ -166,13 +170,23 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // Keep diagnostics on parent stderr ('inherit'); only ACP output contributes // to the result. The seam's scrub drops ambient credentials while spec.env - // (the child's own key) merges after it. + // (the child's own key) merges after it. Explicit DSH_* entries are the + // deployment's facts for the child and take the managed channel — the + // ordinary channel rejects that reserved namespace. + const env: Record = {} + const dshEnv: Record = {} + const isDshKey = (key: string): key is DshEnvironmentKey => key.startsWith(DSH_ENV_PREFIX) + for (const [key, value] of Object.entries(spec.env)) { + if (isDshKey(key)) dshEnv[key] = value + else env[key] = value + } const child = spec.spawn({ argv: [spec.command, ...spec.args], cwd: spec.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs: spec.disposeGraceMs, - env: spec.env, + env, + dshEnv, }) /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ if (child.stdin === undefined || child.stdout === undefined) { diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index 6b3f8157e8..de5900906a 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -4,6 +4,9 @@ * fully scripted by environment variables — no model, no network: * * - `MOCK_TEXT` — the assistant text it streams as one `agent_message_chunk`. + * - `MOCK_ECHO_ENV` — if set to a variable NAME, stream that variable's value + * (or ``) instead of MOCK_TEXT — asserts what + * environment actually reached the child process. * - `MOCK_STOP` — the ACP `StopReason` it returns from `prompt` * (`end_turn` default, or `max_tokens`/`refusal`/…). * - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for @@ -67,7 +70,12 @@ import { type StopReason, } from '@agentclientprotocol/sdk' -const TEXT = process.env.MOCK_TEXT ?? 'mock child answer' +// When MOCK_ECHO_ENV names a variable, stream that variable's value in place +// of MOCK_TEXT — lets a test assert exactly what env reached this process. +const echoEnvName = process.env.MOCK_ECHO_ENV +const TEXT = echoEnvName !== undefined + ? process.env[echoEnvName] ?? `<${echoEnvName} unset>` + : process.env.MOCK_TEXT ?? 'mock child answer' const ECHO_CWD = process.env.MOCK_ECHO_CWD === '1' const STOP = (process.env.MOCK_STOP ?? 'end_turn') as StopReason const HANG = process.env.MOCK_HANG === '1' diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 919e736cb8..7c10463000 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -119,6 +119,21 @@ describe('child env layering (through the subprocess seam)', () => { delete process.env.ACP_TEST_AMBIENT_SECRET_TOKEN } }) + + it('routes explicit DSH_* config entries onto the managed channel', async () => { + // A deployment sets child-harness facts like DSH_PERMISSION_MODE in + // config.env; the run must split them onto the seam's managed channel + // (the ordinary channel rejects the reserved namespace) and the child + // must still see the value. + const ctx = await setup({ MOCK_ECHO_ENV: 'DSH_ACP_TEST_FACT', DSH_ACP_TEST_FACT: 'managed' }) + const parent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent + const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }) + const result = await run.result + await run.dispose() + const text = result.output.filter(b => b.type === 'text').map(b => (b as { text: string }).text).join('') + expect(text).toBe('managed') + await ctx.fiber.dispose() + }) }) describe('cwd resolution', () => { From 43d81b67cef2a957afd2cb0646894757f3f28315 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:21:32 +0800 Subject: [PATCH 37/70] fix(subprocess): consumers with one mixed env map split DSH_* onto the managed channel Codex review of the acp-only fix found lsp-local carries the identical defect: its server config's unrestricted env merges into the connection's ordinary spawn channel, so a configured DSH_* fact crashed the spawn with the reserved-namespace rejection. The partition now lives on the seam as splitEnvChannels() beside the scrub it complements; the ACP run and the LSP connection both use it, and each proves child delivery end-to-end (MOCK_ECHO_ENV / LSP_FAKE_ECHO_ENV fixture knobs). Seam + consumer README rows updated (en+zh, re-recorded). bash-local is already two-channel; mcp/pty/sdk bypass the seam and only share the scrub. --- packages/lsp/lsp-local/README.i18n.yaml | 4 ++-- packages/lsp/lsp-local/README.md | 2 +- packages/lsp/lsp-local/README.zh.md | 2 +- packages/lsp/lsp-local/src/connection.ts | 5 ++++- .../lsp/lsp-local/tests/connection.spec.ts | 9 ++++++++ .../lsp/lsp-local/tests/fixture-server.ts | 8 ++++++- packages/subagent/subagent-acp/src/run.ts | 19 +++++----------- .../subprocess/subprocess/README.i18n.yaml | 4 ++-- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/index.ts | 22 +++++++++++++++++++ .../subprocess/tests/service.spec.ts | 8 ++++++- 12 files changed, 62 insertions(+), 25 deletions(-) diff --git a/packages/lsp/lsp-local/README.i18n.yaml b/packages/lsp/lsp-local/README.i18n.yaml index 9532bc1cb3..41bbe5f7c6 100644 --- a/packages/lsp/lsp-local/README.i18n.yaml +++ b/packages/lsp/lsp-local/README.i18n.yaml @@ -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 -README.md: 462cb12ce96dbbb645c9a19126911d32d4ddd722 -README.zh.md: f5537a416c49106b128188efe4adb2d65304320a +README.md: 3c70977b6ea783d5aa1766709d2a507e08b8ceca +README.zh.md: d24100c5f850172845a24acadcbd2d0207b64859 diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 462cb12ce9..3c70977b6e 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -23,7 +23,7 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v |---|---|---| | `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. | | `args` | `[]` | Arguments passed to the executable. | -| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded). | +| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded); `DSH_*` entries ride the subprocess seam's managed channel. | | `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). | | `initializationOptions` | `null` | Static `initialize` options forwarded to the server. | | `configuration` | `null` | Static answer to every `workspace/configuration` item. | diff --git a/packages/lsp/lsp-local/README.zh.md b/packages/lsp/lsp-local/README.zh.md index f5537a416c..d24100c5f8 100644 --- a/packages/lsp/lsp-local/README.zh.md +++ b/packages/lsp/lsp-local/README.zh.md @@ -23,7 +23,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) |---|---|---| | `command` | (必填) | 要 spawn 的可执行文件:绝对路径,或在加载时从子进程 PATH 解析。不使用 shell 启动。 | | `args` | `[]` | 传给可执行文件的参数。 | -| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`SECRET`/`TOKEN` 的变量不会转发)。 | +| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`SECRET`/`TOKEN` 的变量不会转发);`DSH_*` 条目走 subprocess seam 的受管通道。 | | `extensionToLanguage` | (必填) | 小写、以点开头的扩展名 → LSP language id(例如 `{ '.ts': 'typescript' }`)。 | | `initializationOptions` | `null` | 转发给服务器的静态 `initialize` 选项。 | | `configuration` | `null` | 每个 `workspace/configuration` 配置项的静态答案。 | diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index cbad79ea0d..74f68288e6 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -11,6 +11,7 @@ */ import type { Writable } from 'node:stream' +import { splitEnvChannels } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { encodeMessage, MessageDecoder } from './framing.ts' @@ -98,7 +99,9 @@ export class LspConnection { stderr: { maxBytes: spec.maxStderrBytes }, }, graceMs: spec.pipeDrainGraceMs, - env: spec.env, + // spec.env mixes the scrubbed base with explicit config entries; a + // configured DSH_* fact takes the managed channel the seam reserves. + ...splitEnvChannels(spec.env), }) /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ if (this.handle.stdin === undefined || this.handle.stdout === undefined) { diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index 0fdf223521..c717e3c886 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -51,6 +51,15 @@ describe('LspConnection', () => { expect(conn.pid).toBeGreaterThan(0) }) + it('routes explicit DSH_* env entries onto the managed channel', async () => { + // A configured DSH_* fact must reach the child: the ordinary channel + // rejects the reserved namespace, so the connection's spawn must split it + // onto dshEnv. The fixture echoes the named variable back as hover text. + const conn = connect({ LSP_FAKE_ECHO_ENV: 'DSH_LSP_TEST_FACT', DSH_LSP_TEST_FACT: 'managed' }) + await conn.request('initialize', { capabilities: {} }) + expect(await conn.request('textDocument/hover', {})).toEqual({ contents: 'managed' }) + }) + it('rejects a request when the server replies with an error', async () => { const conn = connect({ LSP_FAKE_ERROR: '1' }) await conn.request('initialize', { capabilities: {} }) diff --git a/packages/lsp/lsp-local/tests/fixture-server.ts b/packages/lsp/lsp-local/tests/fixture-server.ts index 1a30ed5628..9399b5a1a7 100644 --- a/packages/lsp/lsp-local/tests/fixture-server.ts +++ b/packages/lsp/lsp-local/tests/fixture-server.ts @@ -58,7 +58,13 @@ function resultFor(method: string): unknown { case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null) case 'textDocument/references': return envJson('LSP_FAKE_REFS', null) case 'textDocument/implementation': return envJson('LSP_FAKE_IMPL', null) - case 'textDocument/hover': return envJson('LSP_FAKE_HOVER', null) + case 'textDocument/hover': { + // LSP_FAKE_ECHO_ENV names a variable whose VALUE becomes the hover + // contents — a test can assert exactly what env reached this process. + const echoName = process.env.LSP_FAKE_ECHO_ENV + if (echoName !== undefined) return { contents: process.env[echoName] ?? `<${echoName} unset>` } + return envJson('LSP_FAKE_HOVER', null) + } default: return null } } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index d2fc52d876..07358f230c 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -25,8 +25,8 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' -import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' -import type { DshEnvironmentKey, SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { splitEnvChannels } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** Fixed response to child permission requests: reject by default, or select the first allow option. */ export type PermissionPolicy = 'allow' | 'reject' @@ -170,23 +170,14 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // Keep diagnostics on parent stderr ('inherit'); only ACP output contributes // to the result. The seam's scrub drops ambient credentials while spec.env - // (the child's own key) merges after it. Explicit DSH_* entries are the - // deployment's facts for the child and take the managed channel — the - // ordinary channel rejects that reserved namespace. - const env: Record = {} - const dshEnv: Record = {} - const isDshKey = (key: string): key is DshEnvironmentKey => key.startsWith(DSH_ENV_PREFIX) - for (const [key, value] of Object.entries(spec.env)) { - if (isDshKey(key)) dshEnv[key] = value - else env[key] = value - } + // (the child's own key) merges after it; explicit DSH_* entries ride the + // managed channel via the seam's split. const child = spec.spawn({ argv: [spec.command, ...spec.args], cwd: spec.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs: spec.disposeGraceMs, - env, - dshEnv, + ...splitEnvChannels(spec.env), }) /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ if (child.stdin === undefined || child.stdout === undefined) { diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 818069e978..31f0fc4e84 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -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 -README.md: 73e0a4abe49e8d3060f246218694faee127668e9 -README.zh.md: c38a1dd7c15e7d1c0f3139f8942911a4cd9f23fe +README.md: 2cb7a5ebce404c440e625dea844ed28ceadb06f3 +README.zh.md: a3211834e065359e813e8148a8f6a6a15f8f89b6 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 73e0a4abe4..2cb7a5ebce 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -10,7 +10,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. - Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification). -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the function. +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel; `splitEnvChannels()` partitions a consumer config's single mixed env map onto those two channels (lsp-local servers and the ACP backend expose one map, and a configured `DSH_*` fact must ride the managed channel the ordinary one rejects). Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. - Disposal of the service terminates all still-running managed processes and awaits their exit. See the [subprocess data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index c38a1dd7c1..a3211834e0 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -10,7 +10,7 @@ - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 形状:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 - 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。 -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入该函数。 +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实;`splitEnvChannels()` 把消费方配置中单一的混合 env 映射按这两条通道切分(lsp-local 的服务器配置与 ACP 后端只暴露一个映射,而配置的 `DSH_*` 事实必须走受管通道,普通通道会拒绝它)。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入凭据清除函数。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 参见[进程管理器数据结构目录](../../../docs/core-data-structures/subprocess.md)与 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index bbcca90133..e4cfaae901 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -12,6 +12,7 @@ import { Context, Service } from 'cordis' import { DSH_ENV_PREFIX } from './types.ts' +import type { DshEnvironment, DshEnvironmentKey } from './types.ts' import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' export { DSH_ENV_PREFIX } from './types.ts' @@ -61,6 +62,27 @@ export function scrubbedParentEnv(): Record { return env } +/** + * Partition one mixed explicit-env map onto the spec's two channels: `DSH_*` + * names are deployment-owned facts for the child and take the managed + * {@link SubprocessSpawnSpec.dshEnv} channel (the ordinary channel rejects the + * reserved namespace), everything else stays ordinary `env`. For consumers + * whose configs expose a single env map (lsp-local servers, the ACP backend) + * rather than two channel-shaped fields. + * @param env - explicit entries from a consumer's config, both namespaces mixed. + * @returns the two spec channels, each safe for its validator. + */ +export function splitEnvChannels(env: Readonly>): { env: Record; dshEnv: DshEnvironment } { + const ordinary: Record = {} + const managed: Record = {} + const isDshKey = (key: string): key is DshEnvironmentKey => key.startsWith(DSH_ENV_PREFIX) + for (const [key, value] of Object.entries(env)) { + if (isDshKey(key)) managed[key] = value + else ordinary[key] = value + } + return { env: ordinary, dshEnv: managed } +} + declare module 'cordis' { interface Context { subprocess: SubprocessService diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index d033e24a31..9fb68f403f 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { scrubbedParentEnv, SubprocessService } from '@deepseek-ai/dsh-subprocess' +import { scrubbedParentEnv, splitEnvChannels, SubprocessService } from '@deepseek-ai/dsh-subprocess' import type { SubprocessDisposeGraces, SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** @@ -72,4 +72,10 @@ describe('SubprocessService seam', () => { delete process.env.SCRUB_PROBE_PLAIN } }) + + it('splitEnvChannels partitions a mixed map onto the two spec channels', () => { + const { env, dshEnv } = splitEnvChannels({ DSH_FACT: 'managed', PLAIN: 'ordinary', DEEPSEEK_API_KEY: 'explicit' }) + expect(env).toEqual({ PLAIN: 'ordinary', DEEPSEEK_API_KEY: 'explicit' }) + expect(dshEnv).toEqual({ DSH_FACT: 'managed' }) + }) }) From 984c8e1d3e5ad24f9ce13b943d2b4d8d5057ceb9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:25:20 +0800 Subject: [PATCH 38/70] docs: regenerate the cordis services catalog for splitEnvChannels --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1b23a0a1e3..a410f2710b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1582,7 +1582,7 @@ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) -Source: [`packages/subprocess/subprocess/src/index.ts:90`](../../packages/subprocess/subprocess/src/index.ts) +Source: [`packages/subprocess/subprocess/src/index.ts:112`](../../packages/subprocess/subprocess/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` From cb1864795e97cf5a2d90446b13abf3e973e4a2e2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:06:46 +0800 Subject: [PATCH 39/70] docs: prose-standard pass over the PR's added comments and one label Current-state fixes: two test comments still named the pre-rename spawnProcess; two narrated history ('the old whole-chunk drop', 'no longer kills backend work') instead of the pinned contract; and the packages/README row said process-group where the seam's own vocabulary is process-tree (en+zh, pair re-recorded). --- packages/README.i18n.yaml | 4 ++-- packages/README.md | 2 +- packages/README.zh.md | 2 +- packages/bash/bash-local/tests/executor.spec.ts | 2 +- packages/subprocess/subprocess-local/tests/spawn.spec.ts | 8 ++++---- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 6f22ec2128..be7e7c5b67 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -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 -README.md: e56ff806d2fabeb9a921b09897e006ed58c2019a -README.zh.md: 6787385d7ff4d361878e17c2945cc1dbef9c99ad +README.md: 911f18547120eb3dbbc9e42bbcd41e3b6d518cfe +README.zh.md: d6e1f0bf9b38b40944f8e3cebea3f6d90dcaceb5 diff --git a/packages/README.md b/packages/README.md index e56ff806d2..911f185471 100644 --- a/packages/README.md +++ b/packages/README.md @@ -13,7 +13,7 @@ Packages live at `packages///`; groups are containers, while names r | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | | [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | -| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-group implementation | Product — stable surface | +| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface | | [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 6787385d7f..d6e1f0bf9b 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -13,7 +13,7 @@ | [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | | [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 | | [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 | -| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程组实现 | 产品:稳定表面 | +| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 | | [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 | | [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 | | [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:面向模型所写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 | diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index d174b264ab..1395b48958 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -309,7 +309,7 @@ describe('process lifecycle ownership (the subprocess service, not the executor) const pid = Number((await readUntil(proc, '\n')).trim()) expect(Number.isInteger(pid) && pid > 0).toBe(true) - // Reloading/disposing the executor no longer kills backend work — the + // Executor reload/disposal leaves background work running — the // handle stays live and readable, mirroring the task runtime's // registrations-outlive-producer-fibers contract. await executorFiber.dispose() diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 38d4a970bd..4b3e16fd5a 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -144,7 +144,7 @@ describe('spawnSubprocess', () => { }) it('kills the process group with SIGTERM when the signal fires', async () => { - // spawnProcess owns no timer: it kills on abort. The bash executor drives the timeout + // spawnSubprocess owns no timer: it kills on abort. The bash executor drives the timeout // by firing this signal via a deadline (see executor.spec.ts); here we // assert the kill itself lands as SIGTERM. const controller = new AbortController() @@ -350,8 +350,8 @@ describe('OutputCollector', () => { }) it('retains a byte-exact tail across uneven chunk boundaries', () => { - // The old whole-chunk drop could under-retain; a diagnostic tail must be - // exactly the LAST maxBytes regardless of chunking. + // A diagnostic tail must be exactly the LAST maxBytes regardless of + // chunking; dropping only whole chunks would under-retain. const collector = new OutputCollector(10, undefined, 'exact-tail', spillDir) collector.push(Buffer.from('aaaa')) collector.push(Buffer.from('bbbbbb')) @@ -818,7 +818,7 @@ describe('abort edge cases', () => { }) it('reports the terminating signal of an externally self-killed command', async () => { - // spawnProcess reports the raw signal; whether it counts as timeout/cancel is the + // spawnSubprocess reports the raw signal; whether it counts as timeout/cancel is the // executor's classification (a self-kill is neither) — see executor.spec.ts. const result = await finish(spawnSubprocess(spec('kill -TERM $$'))) expect(result.signal).toBe('SIGTERM') From 776d666246d5ded20405802767419eb6f3dab07c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:52:41 +0800 Subject: [PATCH 40/70] refactor(subprocess): the dispose ladder moves to its one consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SubprocessHandle loses dispose(graces) and SubprocessDisposeGraces: the stdin-EOF→SIGTERM→SIGKILL sequence is teardown POLICY encoding one consumer's cooperation shape, not process vocabulary — the seam keeps kill/terminate/waitForExit, and waitForExit(signal?) is the quiescence probe a consumer ladder needs. dsh-subagent-acp owns disposeAcpChild() over those public verbs (tier tests move into its suite; a never-exits stub pins the fail-loud path); dsh-subprocess-local sheds the ladder, its deadline import, and the dsh-timeout dependency. Every future backend now owes four verbs and no teardown policy. New bilingual ladder-ownership Agent Note records the decision; catalogs regenerated. --- ...26-subprocess-consumer-migration.i18n.yaml | 4 +- ...026-07-26-subprocess-consumer-migration.md | 6 +- ...-07-26-subprocess-consumer-migration.zh.md | 6 +- ...07-27-dispose-ladder-to-consumer.i18n.yaml | 6 + .../2026-07-27-dispose-ladder-to-consumer.md | 23 ++++ ...026-07-27-dispose-ladder-to-consumer.zh.md | 23 ++++ docs/cordis-catalog/services.md | 4 +- .../core-data-structures/subprocess.i18n.yaml | 4 +- docs/core-data-structures/subprocess.md | 36 +----- docs/core-data-structures/subprocess.zh.md | 36 +----- .../cordis/tool-cordis/src/api-catalog.ts | 6 +- .../subagent/subagent-acp/README.i18n.yaml | 4 +- packages/subagent/subagent-acp/README.md | 2 +- packages/subagent/subagent-acp/README.zh.md | 2 +- packages/subagent/subagent-acp/src/run.ts | 45 +++++++- .../subagent-acp/tests/subagent-acp.spec.ts | 68 ++++++++++- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/package.json | 2 - .../subprocess/subprocess-local/src/index.ts | 4 +- .../subprocess/subprocess-local/src/spawn.ts | 46 +------- .../subprocess-local/tests/spawn.spec.ts | 107 ++++-------------- .../subprocess/subprocess-local/tsconfig.json | 3 - .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 4 +- packages/subprocess/subprocess/README.zh.md | 4 +- packages/subprocess/subprocess/src/index.ts | 15 ++- packages/subprocess/subprocess/src/types.ts | 33 ------ .../subprocess/tests/service.spec.ts | 4 +- pnpm-lock.yaml | 3 - scripts/type-equiv.manifest.json | 5 - 32 files changed, 223 insertions(+), 294 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md create mode 100644 .agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml index 4326462cbf..43c859efe2 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml @@ -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 -2026-07-26-subprocess-consumer-migration.md: 805f27ba2e72a7f29d1b32053add95b8c33a62e2 -2026-07-26-subprocess-consumer-migration.zh.md: 41bdf04bc03517cf9fe10a61a565e442ecee5520 +2026-07-26-subprocess-consumer-migration.md: 41b2dd12c18cda91937569319d57ff65a804bd55 +2026-07-26-subprocess-consumer-migration.zh.md: d371573548863e404686e317ab763cda5a0d2995 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md index 805f27ba2e..41b2dd12c1 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md @@ -14,10 +14,10 @@ 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, split Node-style**: `kill(signal?)` sends one signal and is a no-op after settlement; `terminate()` owns the SIGTERM→grace→SIGKILL escalation (and serves the spec's abort signal); `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); `dispose(graces)` is the cooperative stdin-EOF→SIGTERM→SIGKILL ladder absorbed from `subagent-subprocess`, memoized per handle. Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. +- **Tree-scoped termination, split Node-style**: `kill(signal?)` sends one signal and is a no-op after settlement; `terminate()` owns the SIGTERM→grace→SIGKILL escalation (and serves the spec's abort signal); `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. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork) and mcp-client (the MCP SDK owns the transport spawn) — import the function, so environment policy is single-sourced even where process ownership is not; 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 `handle.dispose` 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). **`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. @@ -33,6 +33,6 @@ Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` ## Consequences -Bought: one implementation of tree signalling, escalation, the dispose ladder, 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, 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. Cost: the seam is wider — three stdio modes and four termination verbs instead of one of each — 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/test-support spawns remain outside the service by ownership, with the scrub as the shared floor. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md index 41bdf04bc0..d371573548 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -14,10 +14,10 @@ Status: implemented - **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(原始的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 - **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。 -- **以进程树为范围的终止,按 Node 风格拆分**:`kill(signal?)` 只发送一个信号,结算后为空操作;`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(并承接 spec 的 abort 信号);`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界);`dispose(graces)` 是从 `subagent-subprocess` 吸收来的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯,按句柄 memoize 化。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。 +- **以进程树为范围的终止,按 Node 风格拆分**:`kill(signal?)` 只发送一个信号,结算后为空操作;`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(并承接 spec 的 abort 信号);`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 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 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 就是携带插件所配置宽限期的 `handle.dispose` 调用)。**`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 的动词运行,携带插件所配置的宽限期)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 挂载 lsp-local 或 subagent-acp 的组合如今都加载 `dsh-subprocess-local`(这两个插件注入 `'subprocess'`);acp/lsp 测试 fixture(测试前置数据)补上了这一行组合配置。 @@ -33,6 +33,6 @@ Status: implemented ## 后果 -换来的是:进程树信号发送、升级、dispose 阶梯、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 +换来的是:进程树信号发送、升级、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词从一个变为四个),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml new file mode 100644 index 0000000000..bd5964f1a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.i18n.yaml @@ -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 +2026-07-27-dispose-ladder-to-consumer.md: 97b551ff509e3b424f6bf5725939cf54acc961a7 +2026-07-27-dispose-ladder-to-consumer.zh.md: b6849ad393737f2fef06e2007991583b12a04d7a diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md new file mode 100644 index 0000000000..97b551ff50 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.md @@ -0,0 +1,23 @@ +# Agent Note: The dispose ladder belongs to its consumer, not the subprocess seam + +Status: implemented + +English | [中文](2026-07-27-dispose-ladder-to-consumer.zh.md) + +## Problem + +`SubprocessHandle.dispose(graces)` and `SubprocessDisposeGraces` put a full teardown *policy* — stdin-EOF wait, then SIGTERM, then SIGKILL, each tier bounded by a caller-supplied window — on a seam whose other verbs are single mechanisms. Only one consumer ever called it (the ACP subagent backend); bash rides `terminate()` and service teardown, and the LSP host runs its own protocol-first shutdown. Every future backend nonetheless had to implement the ladder to satisfy the interface, and the implementation carried a `dsh-timeout` dependency solely for the ladder's tier bounds. + +## Decision + +The ladder moves to its one consumer. `dsh-subagent-acp` owns `disposeAcpChild(child, eofGraceMs, graceMs)`, built entirely on the seam's public verbs: close `stdin`, bound a `waitForExit` on `eofGraceMs`, then `terminate()` (whose SIGTERM→spec-grace→SIGKILL escalation already encodes the signal tiers), then a final bounded whole-tree wait that throws if survivors remain. The seam keeps `kill`/`terminate`/`waitForExit` — mechanisms, not policy — and `waitForExit(signal?)` is exactly the quiescence probe a consumer ladder needs to hold each tier on real tree exit. `dsh-subprocess-local` drops its `dsh-timeout` dependency; the seam's handle loses one method and one exported interface. + +## Alternatives considered + +**Keep the ladder on the handle as a convenience.** Rejected: a seam method every implementation must provide is not a convenience, it is contract surface — and this one encodes one consumer's cooperation shape (stdin-EOF-first) as if it were process vocabulary. The seam's own README already had to caveat that children quiescing on other signals need "their own tier-1", which is the admission that the ladder is policy. + +**Move the ladder to a shared helper package.** Rejected: one consumer. A second out-of-process backend with the same stdin-EOF cooperation shape can lift `disposeAcpChild` to shared code when it exists; extracting now would recreate `dsh-subagent-subprocess`, the single-purpose library this stack just deleted. + +## Consequences + +Bought: the seam is one method and one type smaller; implementations owe four verbs and no teardown policy; `dsh-subprocess-local` loses a dependency; the ladder's tier windows live beside the config fields that tune them. Cost: a future backend wanting EOF-first teardown writes ~20 lines against the verbs (or lifts the ACP helper); the ladder's tier-tier tests moved from the seam suite to the ACP suite, and the seam suite pins the verbs the ladder composes (bounded `waitForExit` false-then-true across an escalation) instead of the composed policy. diff --git a/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md new file mode 100644 index 0000000000..b6849ad393 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-dispose-ladder-to-consumer.zh.md @@ -0,0 +1,23 @@ +# Agent Note: dispose 阶梯归其消费方所有,而非 subprocess seam + +Status: implemented + +[English](2026-07-27-dispose-ladder-to-consumer.md) | 中文 + +## 问题 + +`SubprocessHandle.dispose(graces)` 与 `SubprocessDisposeGraces` 把一整套拆卸*策略*——等待 stdin EOF、再 SIGTERM、再 SIGKILL,每一层由调用方提供的时间窗约束——放在了一个其余动词均为单一机制的 seam 上。它始终只有一个调用方(ACP subagent 后端);bash 走 `terminate()` 与服务拆卸,LSP 主机运行自己的协议优先关闭流程。然而每个未来后端都必须实现该阶梯才能满足接口,实现包也仅为阶梯的层级时限背上了 `dsh-timeout` 依赖。 + +## 决策 + +阶梯移入其唯一消费方。`dsh-subagent-acp` 拥有 `disposeAcpChild(child, eofGraceMs, graceMs)`,完全构建在 seam 的公开动词之上:关闭 `stdin`,以 `eofGraceMs` 约束一次 `waitForExit`,随后 `terminate()`(其 SIGTERM→spec 宽限期→SIGKILL 升级已编码了信号层级),最后进行有界的整树等待,若仍有存活进程则抛出。seam 保留 `kill`/`terminate`/`waitForExit`——机制而非策略——而 `waitForExit(signal?)` 恰是消费方阶梯在每一层确认进程树真正退出所需的停稳探针。`dsh-subprocess-local` 卸下 `dsh-timeout` 依赖;seam 的句柄少了一个方法和一个导出接口。 + +## 曾考虑的替代方案 + +**把阶梯作为便利方法留在句柄上。**否决:一个每个实现都必须提供的 seam 方法不是便利,而是契约表面——而这一个把某一消费方的配合形状(stdin EOF 打头)当作进程词汇来编码。seam 自己的 README 早已不得不加注「依赖其他信号停稳的子进程需要自己的第一阶」,这本身就是承认该阶梯是策略。 + +**把阶梯移到共享辅助包。**否决:只有一个消费方。当第二个具有相同 stdin EOF 配合形状的进程外后端出现时,可以再把 `disposeAcpChild` 提升为共享代码;现在抽取只会重造 `dsh-subagent-subprocess`——这组堆叠变更刚刚删掉的那个单一用途库。 + +## 后果 + +买到的:seam 少了一个方法和一个类型;实现只欠四个动词,不欠拆卸策略;`dsh-subprocess-local` 少了一个依赖;阶梯的层级时间窗与调节它们的配置字段住在一起。代价:未来想要 EOF 打头拆卸的后端需针对这些动词写约 20 行(或直接搬 ACP 的辅助函数);阶梯的层级测试从 seam 套件移入 ACP 套件,seam 套件转而钉住阶梯所组合的动词(升级前后有界 `waitForExit` 先假后真),而非组合后的策略。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a0b43c245a..abfa64a1af 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1567,7 +1567,7 @@ Implementations must honor these semantics: - spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.kill signals without escalation, SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, and SubprocessHandle.dispose runs the cooperative EOF-first ladder — all tree-scoped on every platform. +- SubprocessHandle.kill signals without escalation, and SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — both tree-scoped on every platform. SubprocessHandle.waitForExit observes whole-tree liveness, so a consumer-owned teardown ladder can hold each tier on real quiescence. - Disposal of the service terminates all still-running managed processes and awaits their exit. ```ts cordis-catalog @@ -1582,7 +1582,7 @@ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) -Source: [`packages/subprocess/subprocess/src/index.ts:112`](../../packages/subprocess/subprocess/src/index.ts) +Source: [`packages/subprocess/subprocess/src/index.ts:111`](../../packages/subprocess/subprocess/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index 0527cf34a3..1e942083be 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/subprocess.i18n.yaml @@ -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 -subprocess.md: cdd4507c7d37f47ca243ddf38114f5aa6b6f3ad1 -subprocess.zh.md: 78325c3255c42ed591bbd98fdbdb4fdfcba48202 +subprocess.md: 810f74b6512c846e138670aa6c3ad1c8be577366 +subprocess.zh.md: 8e6aca436a2a7a14ec4ab1da9b9d4f848918a9dc diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index cdd4507c7d..810f74b651 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -132,7 +132,7 @@ interface SubprocessSpawnSpec { ## Handles: streams, readers, and tree-scoped termination -A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `kill(signal)` sends one signal Node-style, `terminate()` escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need. +A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `kill(signal)` sends one signal Node-style, `terminate()` escalates SIGTERM→grace→SIGKILL, and `waitForExit()` observes the whole tree — enough for a consumer to build its own teardown ladder (the ACP backend's stdin-EOF-first `disposeAcpChild` is the template). ```ts type-equiv /** @@ -177,15 +177,6 @@ interface SubprocessHandle { * @returns `true` when the tree exited, `false` when the signal aborted first. */ waitForExit(signal?: AbortSignal): Promise - /** - * Tear the child down to quiescence, resolving only after exit: close stdin - * (when this handle owns a piped one) and allow cooperative flush for - * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced - * tree termination with a final bounded `graceMs` wait. - * @param graces - the ladder's two windows, from the consumer's Config. - * @throws when the child still has not exited `graceMs` after the forced tier. - */ - dispose(graces: SubprocessDisposeGraces): Promise } ``` @@ -233,31 +224,6 @@ interface SubprocessCollectedOutputs { } ``` -```ts type-equiv -/** - * The two grace periods of the cooperative dispose ladder - * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted, - * validated Config fields, so teardown timing is deployment-tunable and this - * seam hardcodes nothing. - */ -interface SubprocessDisposeGraces { - /** - * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce - * ON ITS OWN — flush durable state, tear down its own descendants — before - * escalation to platform termination. Usually WIDER than - * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven - * teardown may itself wait on a signal-trapping grandchild plus a final - * flush. - */ - eofGraceMs: number - /** - * Termination confirmation window (ms): POSIX applies it after `SIGTERM` - * and again after `SIGKILL`; Windows applies it after the forced tree - * termination. - */ - graceMs: number -} -``` ## Outcomes carry exit facts only diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 78325c3255..8e6aca436a 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -132,7 +132,7 @@ interface SubprocessSpawnSpec { ## 句柄:流、读取器与以进程树为范围的终止 -spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`kill(signal)` 以 Node 风格只发送一个信号,`terminate()` 执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树,`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。 +spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`kill(signal)` 以 Node 风格只发送一个信号,`terminate()` 执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树——这足以让消费方构建自己的拆卸阶梯(ACP 后端以 stdin EOF 打头的 `disposeAcpChild` 即是模板)。 ```ts type-equiv /** @@ -177,15 +177,6 @@ interface SubprocessHandle { * @returns `true` when the tree exited, `false` when the signal aborted first. */ waitForExit(signal?: AbortSignal): Promise - /** - * Tear the child down to quiescence, resolving only after exit: close stdin - * (when this handle owns a piped one) and allow cooperative flush for - * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced - * tree termination with a final bounded `graceMs` wait. - * @param graces - the ladder's two windows, from the consumer's Config. - * @throws when the child still has not exited `graceMs` after the forced tier. - */ - dispose(graces: SubprocessDisposeGraces): Promise } ``` @@ -233,31 +224,6 @@ interface SubprocessCollectedOutputs { } ``` -```ts type-equiv -/** - * The two grace periods of the cooperative dispose ladder - * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted, - * validated Config fields, so teardown timing is deployment-tunable and this - * seam hardcodes nothing. - */ -interface SubprocessDisposeGraces { - /** - * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce - * ON ITS OWN — flush durable state, tear down its own descendants — before - * escalation to platform termination. Usually WIDER than - * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven - * teardown may itself wait on a signal-trapping grandchild plus a final - * flush. - */ - eofGraceMs: number - /** - * Termination confirmation window (ms): POSIX applies it after `SIGTERM` - * and again after `SIGKILL`; Windows applies it after the forced tree - * termination. - */ - graceMs: number -} -``` ## 结果只承载退出事实 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index cf879df592..57d71fef7a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2221,13 +2221,9 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubprocessCollectedOutputs', declaration: 'export interface SubprocessCollectedOutputs {\n readonly stdout?: SubprocessOutputReader;\n readonly stderr?: SubprocessOutputReader;\n}', }, - { - name: 'SubprocessDisposeGraces', - declaration: 'export interface SubprocessDisposeGraces {\n eofGraceMs: number;\n graceMs: number;\n}', - }, { name: 'SubprocessHandle', - declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n kill(signal?: NodeJS.Signals): void;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n dispose(graces: SubprocessDisposeGraces): Promise;\n}', + declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n kill(signal?: NodeJS.Signals): void;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n}', }, { name: 'SubprocessOutcome', diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 61f41f5665..a6b976c516 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/README.i18n.yaml @@ -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 -README.md: 0c21e179bd5ca405057b682786c41c8447a78317 -README.zh.md: b349443cfcda80caa9b06cfa1cf80f6e780a98be +README.md: e573e22a02a301e18e341ea13c967718f6a7f625 +README.zh.md: 0522706fe0f0e3263c0c229812871ea17ebe8143 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 0c21e179bd..e573e22a02 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -14,7 +14,7 @@ The returned run id is minted in the parent namespace. The child server's sessio After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation. -`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, and waits `disposeEofGraceMs`. POSIX then escalates through SIGTERM and `disposeGraceMs` before SIGKILL; Windows force-terminates directly because Node maps both signals to `TerminateProcess`. After forced termination, every platform waits at most `disposeGraceMs` for exit and rejects on a signal error or missing exit. Every run uses a fresh process; process pooling is not implemented. +`dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, then runs this backend's own teardown ladder (`disposeAcpChild`) over the seam's verbs: close stdin and wait `disposeEofGraceMs` for cooperative quiescence, then the handle's `terminate()` escalation (SIGTERM, the spawn grace, SIGKILL — Windows force-terminates directly), then a bounded whole-tree exit wait that rejects if survivors remain. Every run uses a fresh process; process pooling is not implemented. ## Capabilities and context diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index b349443cfc..0522706fe0 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -14,7 +14,7 @@ ACP(Agent Client Protocol)提供方会在全新的子进程中运行每个 s 发布后,提供方发送提示词,并把流式 `agent_message_chunk` 文本收集到 `SubagentResult.output`。提示词/传输失败会以 `stopReason: 'error'` 兑现;如果必需的请求信号或 dispose 请求了取消,则以 `aborted` 兑现。 -`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,关闭 stdin,并等待 `disposeEofGraceMs`。随后 POSIX 先升级到 SIGTERM,等待 `disposeGraceMs` 后再使用 SIGKILL;Windows 会直接强制终止,因为 Node 会把两个信号都映射到 `TerminateProcess`。强制终止后,各平台最多再等待 `disposeGraceMs` 以确认退出;若信号出错或未退出,则拒绝。每次运行都使用全新进程;尚未实现进程池。 +`dispose()` 是幂等的。它会移除信号监听器,在可行时请求 ACP 取消,然后经由该 seam 的动词运行本后端自有的拆卸阶梯(`disposeAcpChild`):先关闭 stdin 并等待 `disposeEofGraceMs` 让子进程协作停稳,再触发句柄的 `terminate()` 升级(SIGTERM、spawn 宽限期、SIGKILL——Windows 直接强制终止),最后进行有界的整树退出等待;若仍有存活进程,则拒绝。每次运行都使用全新进程;尚未实现进程池。 ## 能力与上下文 diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 07358f230c..34438f4f2a 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -91,6 +91,46 @@ export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 /** Default POSIX grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/** Bounded whole-tree exit wait: polls the handle's tree liveness until it exits or `ms` elapses. */ +async function treeExitsWithin(child: SubprocessHandle, ms: number): Promise { + const controller = new AbortController() + const timer = setTimeout(() => { controller.abort() }, ms) + try { + return await child.waitForExit(controller.signal) + } finally { + clearTimeout(timer) + } +} + +/** + * Cooperative teardown ladder for an out-of-process agent, over the seam's + * public verbs; resolves only at whole-tree quiescence: stdin EOF (the child's + * window to flush persistence and reap its own descendants), then the + * terminate() escalation (SIGTERM → spec grace → SIGKILL), then a bounded + * confirmation wait. + * @param child - the spawned ACP child's handle. + * @param eofGraceMs - tier-1 window after stdin EOF. + * @param graceMs - confirmation window after the escalation's SIGKILL. + * @throws when the tree still has not exited `graceMs` after forced termination. + */ +export async function disposeAcpChild(child: SubprocessHandle, eofGraceMs: number, graceMs: number): Promise { + // A spawn failure has no process to tear down; observe the rejection so + // disposal in a finally block cannot surface it as unhandled. + if (child.pid <= 0) { + await child.done.catch(() => {}) + return + } + child.stdin?.end() + if (await treeExitsWithin(child, eofGraceMs)) return + // terminate() sends SIGTERM now and SIGKILL after the spawn spec's grace + // (this plugin passes disposeGraceMs there), so the bound covers both the + // escalation window and an equal confirmation window after the SIGKILL. + child.terminate() + if (!(await treeExitsWithin(child, graceMs * 2))) { + throw new Error('ACP child process tree did not exit within its dispose windows') + } +} + /** * Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}. * @param reason - the terminal reason from the child's `session/prompt` response. @@ -197,10 +237,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // Startup rollback and the published handle share one process teardown. let processDisposal: Promise | undefined - const disposeProcess = (): Promise => (processDisposal ??= child.dispose({ - eofGraceMs: spec.disposeEofGraceMs, - graceMs: spec.disposeGraceMs, - })) + const disposeProcess = (): Promise => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs, spec.disposeGraceMs)) // Accumulate the child's streamed assistant text — the SubagentResult output. const output: string[] = [] diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 7c10463000..0ab5916de9 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' import * as acp from '../src/index.ts' -import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' +import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts' @@ -136,6 +136,72 @@ describe('child env layering (through the subprocess seam)', () => { }) }) +describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)', () => { + const bash = (command: string, stdin: 'pipe' | 'ignore' = 'pipe') => spawnSubprocess({ + argv: ['bash', '-c', command], + cwd: process.cwd(), + stdio: { stdin, stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + graceMs: 200, + }) + + it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => { + const child = bash('read -r line; exit 0') + await disposeAcpChild(child, 5_000, 200) + const outcome = await child.done + expect(outcome.exitCode).toBe(0) + expect(outcome.signal).toBeNull() + }) + + it('tier 2: an EOF-deaf child dies by the terminate escalation (SIGTERM)', async () => { + const child = bash('sleep 60') + await disposeAcpChild(child, 100, 5_000) + const outcome = await child.done + expect(outcome.signal).toBe('SIGTERM') + }) + + it('tier 3: a TERM-trapping child dies by the escalation SIGKILL', async () => { + const child = bash("trap '' TERM; echo armed; sleep 60", 'ignore') + // Wait for the trap to arm so SIGTERM cannot race the default handler. + while (!child.collected.stdout!.readFrom(0).text.includes('armed')) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + await disposeAcpChild(child, 50, 2_000) + const outcome = await child.done + expect(outcome.signal).toBe('SIGKILL') + }) + + it('throws when the tree survives even the escalation window', async () => { + // A handle whose tree never exits (waitForExit only ever aborts): the + // ladder must fail loud instead of resolving over survivors. Built as a + // stub because the ladder composes only public verbs. + const never: Parameters[0] = { + pid: 1, + stdin: undefined, + stdout: undefined, + stderr: undefined, + collected: {}, + done: new Promise(() => {}), + kill: () => {}, + terminate: () => {}, + waitForExit: (signal?: AbortSignal) => new Promise((resolve) => { + signal?.addEventListener('abort', () => { resolve(false) }, { once: true }) + }), + } + await expect(disposeAcpChild(never, 20, 20)).rejects.toThrow(/did not exit within its dispose windows/) + }) + + it('observes a spawn-level rejection and returns without a process to reap', async () => { + const child = spawnSubprocess({ + argv: ['bash', '-c', 'true'], + cwd: '/nonexistent-dir-dsh-acp-ladder-test', + stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, + graceMs: 200, + }) + await expect(disposeAcpChild(child, 1_000, 1_000)).resolves.toBeUndefined() + await expect(child.done).rejects.toThrow() + }) +}) + describe('cwd resolution', () => { it('falls back to the parent session cwd for the child process AND its ACP session', async () => { // realpath: on macOS `tmpdir()` sits behind a symlink (/var → /private/var), diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 2e8c1fdae4..f34c84e520 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -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 -README.md: 08cc2ce7d92569222b99992d0f4c43551a2c9623 -README.zh.md: da230ba37d406a6ad4ec669ceead45f2c2dd7069 +README.md: 16d83032ebf271a3e3e091962cad6dc9b689f072 +README.zh.md: e9a1284a81269741909631a6fbac8af3549800a3 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 08cc2ce7d9..16d83032eb 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README ## Behavior (and where it came from) -- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. +- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `waitForExit()` polls whole-tree liveness so consumer teardown confirms real quiescence. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index da230ba37d..e9a1284a81 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,7 +6,7 @@ ## 行为(以及设计来源) -- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 +- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`waitForExit()` 轮询整棵进程树的存活状态,使消费方的拆卸能确认真正的完全停稳。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。 - **凭据清除 + 受管 `DSH_*` 合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的普通 `env` 在清除后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止陈旧的嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 871b4cfac6..72ff50c422 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -29,13 +29,11 @@ "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index d71db5622a..eea1f1e739 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -16,8 +16,8 @@ import type { SpawnInternals } from './spawn.ts' /** * Local subprocess service: detached process trees, Node-shaped stdio * dispositions (raw pipes, inherit, bounded tail-keep collection with spill - * files), credential-scrubbed environment, tree-scoped signalling with - * SIGTERM→grace→SIGKILL escalation, and the cooperative dispose ladder. + * files), credential-scrubbed environment, and tree-scoped signalling with + * SIGTERM→grace→SIGKILL escalation. */ export class LocalSubprocessService extends SubprocessService { /** Live handles retained only so disposal can terminate and join them. */ diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 8716f50b0c..bc716391e9 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -1,10 +1,9 @@ /** * Process plumbing for the local subprocess service: detached process-tree * spawn with per-stream stdio dispositions, tail-keep collection with spill - * files, tree-scoped signalling (POSIX groups; Windows taskkill), the - * SIGTERM→SIGKILL escalation, and the cooperative EOF-first dispose ladder. - * This layer reacts to an abort signal; callers own deadlines and classify - * causes. + * files, tree-scoped signalling (POSIX groups; Windows taskkill), and the + * SIGTERM→SIGKILL escalation. This layer reacts to an abort signal; callers + * own deadlines, teardown ladders, and cause classification. * @module dsh-subprocess-local/spawn */ @@ -15,13 +14,11 @@ import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as sleepMs } from 'node:timers/promises' -import { deadline } from '@deepseek-ai/dsh-timeout' import { DSH_ENV_PREFIX, scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { CollectedOutput, DshEnvironment, SubprocessCollect, - SubprocessDisposeGraces, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, @@ -63,9 +60,6 @@ export interface SpawnInternals { platform?: NodeJS.Platform } -/** Timeout code marking a dispose-ladder tier bound (vs an external abort). */ -const DISPOSE_TIER_TIMEOUT = 'SUBPROCESS_DISPOSE_TIER' - /** * Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an * awaited teardown must keep the event loop alive until the tree really @@ -469,39 +463,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter return true } - /** - * Wait, bounded, for whole-tree exit — the dispose ladder's quiescence test. - * Tree liveness, not direct-child settlement: a TERM-trapping helper that - * outlives the leader must hold the ladder on its tier until it exits. - */ - const treeExitsWithin = async (ms: number): Promise => { - using bound = deadline(undefined, ms, DISPOSE_TIER_TIMEOUT) - return await waitForExit(bound.signal) - } - - let disposal: Promise | undefined - const dispose = (graces: SubprocessDisposeGraces): Promise => (disposal ??= (async () => { - // A spawn failure has no process to tear down; observe the rejection so - // disposal in a finally block cannot surface it as unhandled. - if (pid <= 0) { - await done.catch(() => {}) - return - } - // 1. Close a piped stdin and allow cooperative teardown and flush. - if (stdinMode === 'pipe') child.stdin?.end() - if (await treeExitsWithin(graces.eofGraceMs)) return - // 2. POSIX gets a catchable graceful signal; Windows taskkill force-terminates. - if (platform !== 'win32') { - kill('SIGTERM') - if (await treeExitsWithin(graces.graceMs)) return - } - // 3. Force-kill the tree and await a bounded exit edge. - kill('SIGKILL') - if (!(await treeExitsWithin(graces.graceMs))) { - throw new Error(`child process tree did not exit within ${graces.graceMs}ms after forced termination`) - } - })()) - return { pid, /* v8 ignore start -- pipe-mode fds exist on every spawn Node returns; the null-coalesces guard a nonconforming ChildProcess only. */ @@ -517,6 +478,5 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter kill, terminate, waitForExit, - dispose, } } diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 4b3e16fd5a..7936217b04 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -506,40 +506,6 @@ describe('stdio dispositions', () => { }) }) -describe('dispose ladder', () => { - it('tier 1: a cooperative child exits on stdin EOF without any signal', async () => { - const running = spawnSubprocess({ - ...spec('read -r line; exit 0'), - stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, - }) - await running.dispose({ eofGraceMs: 5_000, graceMs: 200 }) - const outcome = await running.done - expect(outcome.exitCode).toBe(0) - expect(outcome.signal).toBeNull() - }) - - it('tier 2: an EOF-deaf child dies by SIGTERM', async () => { - const running = spawnSubprocess({ - ...spec('sleep 60'), - stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, - }) - await running.dispose({ eofGraceMs: 100, graceMs: 5_000 }) - const outcome = await running.done - expect(outcome.signal).toBe('SIGTERM') - }) - - it('tier 3: a TERM-trapping child dies by SIGKILL, and dispose() is idempotent', async () => { - const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60')) - await waitForStdout(running, 'armed\n') - const first = running.dispose({ eofGraceMs: 50, graceMs: 200 }) - const second = running.dispose({ eofGraceMs: 50, graceMs: 200 }) - expect(second).toBe(first) - await first - const outcome = await running.done - expect(outcome.signal).toBe('SIGKILL') - }) -}) - describe('windows tree semantics (injected platform)', () => { it('kill and terminate route through taskkill by root pid', async () => { const killed: number[] = [] @@ -590,7 +556,7 @@ describe('waitForExit', () => { }) }) -describe('tree-survivor escalation (terminate/dispose reach helpers the leader left behind)', () => { +describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => { it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => { // The leader spawns a TERM-trapping helper with all stdio detached from // the collected pipes, then exits: the helper holds the GROUP alive while @@ -609,18 +575,21 @@ describe('tree-survivor escalation (terminate/dispose reach helpers the leader l await waitGone(helper) }) - it('dispose() holds each tier on whole-tree exit, not direct-child settlement', async () => { - const pidFile = join(spillDir, `survivor-dispose-${Date.now()}.pid`) + it('a bounded waitForExit reports false while a survivor lives, true after escalation', async () => { + const pidFile = join(spillDir, `survivor-wait-${Date.now()}.pid`) const running = spawnSubprocess(spec( `bash -c 'trap "" TERM; echo $$ > ${pidFile}; sleep 60' >/dev/null 2>&1 & disown; exit 0`, { graceMs: 200 }, )) const helper = await waitForPidFile(pidFile) await running.done - expect(() => process.kill(helper, 0)).not.toThrow() - - await running.dispose({ eofGraceMs: 100, graceMs: 300 }) - // The ladder only returns once the WHOLE tree is gone. + // A consumer-owned teardown tier bounds its wait and reads the verdict. + const bound = new AbortController() + const timer = setTimeout(() => { bound.abort() }, 100) + await expect(running.waitForExit(bound.signal)).resolves.toBe(false) + clearTimeout(timer) + running.terminate() + await expect(running.waitForExit()).resolves.toBe(true) expect(() => process.kill(helper, 0)).toThrow() }) @@ -653,11 +622,10 @@ describe('coverage seams', () => { expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow() }) - it('dispose on a spawn-failed handle observes the rejection and returns', async () => { + it('a spawn-failed handle rejects done while waitForExit reports gone', async () => { const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' })) - const disposal = running.dispose({ eofGraceMs: 1_000, graceMs: 1_000 }) await expect(running.done).rejects.toThrow() - await expect(disposal).resolves.toBeUndefined() + await expect(running.waitForExit()).resolves.toBe(true) }) it("an 'inherit' stdout with collected stderr wires only the requested collector", async () => { @@ -692,25 +660,10 @@ describe('coverage seams', () => { await expect(running.waitForExit()).resolves.toBe(true) }) - it('dispose() on an already-exited tree returns without delivering a signal', async () => { - const running = spawnSubprocess(spec('true')) - await running.done - await running.waitForExit() - const spy = vi.spyOn(process, 'kill') - try { - await running.dispose({ eofGraceMs: 50, graceMs: 50 }) - const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0) - expect(delivered).toEqual([]) - } finally { - spy.mockRestore() - } - }) - - it('a batch-stdin handle exposes no stdin and dispose skips the EOF tier', async () => { + it('a batch-stdin handle exposes no stdin surface', async () => { const running = spawnSubprocess(spec('cat', { stdin: 'batch\n' })) expect(running.stdin).toBeUndefined() await running.done - await running.dispose({ eofGraceMs: 50, graceMs: 50 }) expect(running.collected.stdout!.readFrom(0).text).toBe('batch\n') }) }) @@ -739,33 +692,15 @@ describe('coverage seams 2', () => { await expect(running.waitForExit()).resolves.toBe(true) }) - it('the win32 dispose ladder skips the POSIX SIGTERM tier and force-terminates', async () => { - const kills: number[] = [] - const running = spawnSubprocess({ - ...spec('sleep 60'), - stdio: { stdin: 'pipe', stdout: { maxBytes: 1000 }, stderr: { maxBytes: 1000 } }, - }, { - spillDir, - platform: 'win32', - taskkill: (pid) => { - kills.push(pid) - try { - process.kill(pid, 'SIGKILL') - } catch { - // Already gone. - } - }, - }) - await running.dispose({ eofGraceMs: 50, graceMs: 5_000 }) - // Exactly one forced tree termination: no POSIX SIGTERM tier ran. - expect(kills).toEqual([running.pid]) - }) - - it('dispose throws when even SIGKILL produces no exit within the grace', async () => { - // An inert taskkill simulates a tree that never reports exit. + it('an inert win32 taskkill leaves the tree alive for a bounded wait to report', async () => { + // An inert taskkill simulates a tree that never reports exit: terminate() + // delivers nothing, so a bounded consumer wait must come back false. const running = spawnSubprocess(spec('sleep 60'), { spillDir, platform: 'win32', taskkill: () => {} }) - await expect(running.dispose({ eofGraceMs: 20, graceMs: 40 })) - .rejects.toThrow(/did not exit within 40ms after forced termination/) + running.terminate() + const bound = new AbortController() + const timer = setTimeout(() => { bound.abort() }, 60) + await expect(running.waitForExit(bound.signal)).resolves.toBe(false) + clearTimeout(timer) // Real cleanup: the injected platform spawned without detachment, so the // child is a plain (group-less) POSIX process — kill it directly. process.kill(running.pid, 'SIGKILL') diff --git a/packages/subprocess/subprocess-local/tsconfig.json b/packages/subprocess/subprocess-local/tsconfig.json index 5272a4f78d..5a8dea211b 100644 --- a/packages/subprocess/subprocess-local/tsconfig.json +++ b/packages/subprocess/subprocess-local/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../subprocess" }, - { - "path": "../../util/timeout" - }, { "path": "../../support/invariants" } diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 31f0fc4e84..a48a16cb26 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -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 -README.md: 2cb7a5ebce404c440e625dea844ed28ceadb06f3 -README.zh.md: a3211834e065359e813e8148a8f6a6a15f8f89b6 +README.md: 271e9b912077bf5ec18d96666c216562cb87cc6e +README.zh.md: 692108ffe8848f823b3ab3fa487a2bf0fbc8fa44 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 2cb7a5ebce..271e9b9120 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -9,7 +9,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes - `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures. - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. -- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification). +- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, and `waitForExit(signal?)` observes whole-tree liveness so a consumer-owned teardown ladder holds each tier on real quiescence — the manager reacts but never classifies why (callers own deadlines, teardown ladders, and cause classification). - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel; `splitEnvChannels()` partitions a consumer config's single mixed env map onto those two channels (lsp-local servers and the ACP backend expose one map, and a configured `DSH_*` fact must ride the managed channel the ordinary one rejects). Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. - Disposal of the service terminates all still-running managed processes and awaits their exit. @@ -26,4 +26,4 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **node-pty and SDK-managed spawns share only the scrub** — the PTY backend's terminal fork and the MCP SDK's own stdio transport cannot route their spawns through this seam (the library owns the fork/spawn call); they import `scrubbedParentEnv` so the environment policy stays single-sourced. -- **The dispose ladder assumes stdin-EOF cooperation** — a child that quiesces on a different signal (SIGHUP conventions, control sockets) needs its own tier-1 before the generic ladder fits. +- **Teardown ladders are consumer-owned** — the seam ships signalling verbs and the tree-liveness wait, not a canned quiesce sequence; each out-of-process consumer encodes its child's cooperation shape itself (the ACP backend's stdin-EOF-first ladder is the in-repo template). diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index a3211834e0..692108ffe8 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -9,7 +9,7 @@ - `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 形状:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 -- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。 +- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit(signal?)` 观察整棵进程树的存活状态,使消费方自有的拆卸阶梯能在真正完全停稳后才进入下一层。管理器只响应中止,但绝不判定原因(deadline、拆卸阶梯与原因分类归调用方所有)。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实;`splitEnvChannels()` 把消费方配置中单一的混合 env 映射按这两条通道切分(lsp-local 的服务器配置与 ACP 后端只暴露一个映射,而配置的 `DSH_*` 事实必须走受管通道,普通通道会拒绝它)。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入凭据清除函数。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 @@ -26,4 +26,4 @@ ## 已知限制与暂缓事项 - **node-pty 与由 SDK 管理的 spawn 只共享凭据清除**:PTY 后端的终端 fork 与 MCP SDK 自己的 stdio 传输层无法把 spawn 路由到这道 seam(fork/spawn 调用归库所有);它们改为导入 `scrubbedParentEnv`,使环境策略保持单一来源。 -- **dispose 阶梯假定子进程配合 stdin EOF**:依赖其他信号(SIGHUP 惯例、控制 socket)才能完全停稳的子进程,需要自己的第一阶,通用阶梯才适用。 +- **拆卸阶梯归消费方所有**:该 seam 只提供信号动词与进程树存活等待,不提供现成的停稳序列;每个进程外消费方自行编码其子进程的配合形状(ACP 后端以 stdin EOF 打头的阶梯是仓库内模板)。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index e4cfaae901..8001e1655f 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -2,10 +2,9 @@ * The subprocess seam (`ctx.subprocess`): spawn fully-specified commands into * managed process trees with Node-shaped stdio dispositions — raw pipes for * protocol streams, inherit for diagnostics, bounded spill-backed collection - * for batch output — plus tree-scoped signalling and a cooperative dispose - * ladder. Command defaulting, shell semantics, deadlines, framing, and - * presentation belong to consumers; the bash executor seam is the owning - * template. The local implementation lives in + * for batch output — plus tree-scoped signalling. Command defaulting, shell + * semantics, deadlines, teardown ladders, framing, and presentation belong to + * consumers; the bash executor seam is the owning template. The local implementation lives in * `@deepseek-ai/dsh-subprocess-local`. * @module @deepseek-ai/dsh-subprocess */ @@ -22,7 +21,6 @@ export type { DshEnvironmentKey, SubprocessCollect, SubprocessCollectedOutputs, - SubprocessDisposeGraces, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, @@ -102,10 +100,11 @@ declare module 'cordis' { * readers never consume one another's output; lossy reads report truncation * and the spill file holding the complete stream when one exists. Piped * streams are handed to the caller raw and never buffered here. - * - {@link SubprocessHandle.kill} signals without escalation, + * - {@link SubprocessHandle.kill} signals without escalation, and * {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates - * SIGTERM→grace→SIGKILL, and {@link SubprocessHandle.dispose} runs the - * cooperative EOF-first ladder — all tree-scoped on every platform. + * SIGTERM→grace→SIGKILL — both tree-scoped on every platform. + * {@link SubprocessHandle.waitForExit} observes whole-tree liveness, so a + * consumer-owned teardown ladder can hold each tier on real quiescence. * - Disposal of the service terminates all still-running managed processes * and awaits their exit. */ diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 4c667919c7..b2e0740d01 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -160,30 +160,6 @@ export interface SubprocessCollectedOutputs { readonly stderr?: SubprocessOutputReader } -/** - * The two grace periods of the cooperative dispose ladder - * ({@link SubprocessHandle.dispose}). Consumers carry them as defaulted, - * validated Config fields, so teardown timing is deployment-tunable and this - * seam hardcodes nothing. - */ -export interface SubprocessDisposeGraces { - /** - * Tier-1 window (ms): after stdin EOF, how long the child gets to quiesce - * ON ITS OWN — flush durable state, tear down its own descendants — before - * escalation to platform termination. Usually WIDER than - * {@link SubprocessDisposeGraces.graceMs}: a cooperative child's EOF-driven - * teardown may itself wait on a signal-trapping grandchild plus a final - * flush. - */ - eofGraceMs: number - /** - * Termination confirmation window (ms): POSIX applies it after `SIGTERM` - * and again after `SIGKILL`; Windows applies it after the forced tree - * termination. - */ - graceMs: number -} - /** * A live child process rooted in its own process tree. Collected output * remains readable after exit; piped streams belong to the caller. @@ -226,13 +202,4 @@ export interface SubprocessHandle { * @returns `true` when the tree exited, `false` when the signal aborted first. */ waitForExit(signal?: AbortSignal): Promise - /** - * Tear the child down to quiescence, resolving only after exit: close stdin - * (when this handle owns a piped one) and allow cooperative flush for - * `eofGraceMs`, then SIGTERM with a `graceMs` window (POSIX), then forced - * tree termination with a final bounded `graceMs` wait. - * @param graces - the ladder's two windows, from the consumer's Config. - * @throws when the child still has not exited `graceMs` after the forced tier. - */ - dispose(graces: SubprocessDisposeGraces): Promise } diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 9fb68f403f..612d349786 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { scrubbedParentEnv, splitEnvChannels, SubprocessService } from '@deepseek-ai/dsh-subprocess' -import type { SubprocessDisposeGraces, SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** * Minimal concrete service: a hand-built handle. The seam is spawn-only — @@ -24,7 +24,6 @@ class StubSubprocessService extends SubprocessService { kill: () => {}, terminate: () => {}, waitForExit: () => Promise.resolve(true), - dispose: (_graces: SubprocessDisposeGraces) => Promise.resolve(), } } } @@ -44,7 +43,6 @@ describe('SubprocessService seam', () => { handle.kill() handle.terminate() await expect(handle.waitForExit()).resolves.toBe(true) - await expect(handle.dispose({ eofGraceMs: 1, graceMs: 1 })).resolves.toBeUndefined() const outcome = await handle.done expect(outcome.exitCode).toBe(0) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af57e80494..d9f00ed502 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3851,9 +3851,6 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../subprocess - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 811ebd40c9..53d32758e0 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1303,11 +1303,6 @@ "doc": "docs/core-data-structures/subprocess.md", "symbol": "SubprocessCollectedOutputs", "source": "packages/subprocess/subprocess/src/types.ts" - }, - { - "doc": "docs/core-data-structures/subprocess.md", - "symbol": "SubprocessDisposeGraces", - "source": "packages/subprocess/subprocess/src/types.ts" } ] } From 571772683596952e48b538027597f123c5bb2e5d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:14:51 +0800 Subject: [PATCH 41/70] subprocess: one explicit env channel on the spawn spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop SubprocessSpawnSpec.dshEnv and splitEnvChannels(); childEnv() is now scrubbed-base + explicit entries with no namespace validation. The invariant dropped is the reserved-namespace check on explicit entries (DSH_* rejected from env, non-DSH_* rejected from dshEnv). Explicit-entry trust already covers it: an explicit credential-shaped entry has always merged after the scrub as a deliberate caller opt-in, and an explicit DSH_* entry is the same deliberate act — the staleness invariant lives entirely in scrubbedParentEnv dropping AMBIENT credential-shaped and DSH_* names, which stays. The validation's only observed effect was rejecting legitimate explicit entries: both recent CI breakages (DSH_GATE_CONCURRENCY exported into every job crashing lsp specs, DSH_PERMISSION_MODE in acp config.env crashing the child spawn) were this check firing on values a caller meant to pass, each fixed by routing around the bureaucracy the seam itself imposed. The bash seam keeps its own request/spec dshEnv field: that is bash-owned trusted-plugin vocabulary (the ctx.bashEnv collected overlay) whose merge-last position guarantees a caller env entry cannot displace a managed fact; bash-local now flattens ENV_OVERRIDES -> spec.env -> spec.dshEnv into the seam's one env map. subagent-acp and lsp-local pass their single config env map straight through. DshEnvironment/DshEnvironmentKey/DSH_ENV_PREFIX stay on the subprocess seam as the namespace vocabulary (bash re-exports them; scrubbedParentEnv filters on the prefix). Tests: the two channel-rejection specs and the splitEnvChannels partition spec are deleted; one spawn spec now proves an explicit DSH_* env entry reaches the child while an ambient one is scrubbed; the acp/lsp forwarding specs keep their MOCK_ECHO_ENV / LSP_FAKE_ECHO_ENV assertions with the split comments rewritten to merge-after-scrub. Docs (en+zh, re-recorded) and the owning Agent Notes updated; cordis api/services catalogs regenerated. --- ...stdin-env-trusted-plugin-surface.i18n.yaml | 4 +-- ...0-bash-stdin-env-trusted-plugin-surface.md | 2 +- ...ash-stdin-env-trusted-plugin-surface.zh.md | 2 +- .../2026-07-26-subprocess-seam.i18n.yaml | 4 +-- .../2026-07-26-subprocess-seam.md | 4 +-- .../2026-07-26-subprocess-seam.zh.md | 4 +-- ...ession-identity-and-log-location.i18n.yaml | 4 +-- ...agent-session-identity-and-log-location.md | 4 +-- ...nt-session-identity-and-log-location.zh.md | 4 +-- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/bash.i18n.yaml | 4 +-- docs/core-data-structures/bash.md | 21 +++++++------ docs/core-data-structures/bash.zh.md | 21 +++++++------ .../core-data-structures/subprocess.i18n.yaml | 4 +-- docs/core-data-structures/subprocess.md | 19 +++++------- docs/core-data-structures/subprocess.zh.md | 19 +++++------- packages/bash/bash-local/src/index.ts | 10 +++--- packages/bash/bash/README.i18n.yaml | 4 +-- packages/bash/bash/README.md | 2 +- packages/bash/bash/README.zh.md | 2 +- packages/bash/bash/src/types.ts | 21 +++++++------ .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/lsp/lsp-local/README.i18n.yaml | 4 +-- packages/lsp/lsp-local/README.md | 2 +- packages/lsp/lsp-local/README.zh.md | 2 +- packages/lsp/lsp-local/src/connection.ts | 8 ++--- .../lsp/lsp-local/tests/connection.spec.ts | 8 ++--- .../subagent/subagent-acp/README.i18n.yaml | 4 +-- packages/subagent/subagent-acp/README.md | 2 +- packages/subagent/subagent-acp/README.zh.md | 2 +- packages/subagent/subagent-acp/src/run.ts | 12 +++---- .../subagent-acp/tests/subagent-acp.spec.ts | 7 ++--- .../subprocess-local/README.i18n.yaml | 4 +-- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/src/spawn.ts | 31 +++++-------------- .../subprocess-local/tests/spawn.spec.ts | 18 +++-------- .../subprocess/subprocess/README.i18n.yaml | 4 +-- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/index.ts | 31 +++---------------- packages/subprocess/subprocess/src/types.ts | 17 ++++------ .../subprocess/tests/service.spec.ts | 8 +---- 43 files changed, 134 insertions(+), 201 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml index 51d0eb9a78..690aa1f9b4 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml @@ -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 -2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 284cd45a66294dbc9e8207a1e00e9642d32d4e58 -2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9486f8c35c5060150b072fb673acca5d4167ec1a +2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 556d5dd86dfcc92c4628e68c19390f0033560d25 +2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9d67797f86903e70e7bdcd6f80f19d17c41ac18e diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md index 284cd45a66..556d5dd86d 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -18,7 +18,7 @@ Three deliberate choices: 1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. Harness-owned variables use the separate `dshEnv` channel from the [managed environment decision](../feature/2026-07-10-agent-session-identity-and-log-location.md), so ordinary `env` cannot replace them. -2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision reserves `DSH_*`: ambient entries are removed, ordinary `env` cannot set them, and trusted `dshEnv` merges last. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`. +2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision manages `DSH_*`: ambient entries are removed, and trusted `dshEnv` merges last, so an ordinary `env` entry can never displace a managed value. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`. 3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`. diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md index 9486f8c35c..9d67797f86 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md @@ -18,7 +18,7 @@ Status: implemented 1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。 -2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策保留 `DSH_*`:环境条目会被移除,普通 `env` 无法设置它们,受信的 `dshEnv` 最后合并。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 +2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策托管 `DSH_*`:环境条目会被移除,受信的 `dshEnv` 最后合并,因此普通 `env` 条目永远无法顶掉托管值。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 3. **`stdin`/`env` 在已解析 spec 上是 required-absent-OK(普通 optional),而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable,是因为*静默*缺失的 owner 会产生一个无主、跨会话可读的任务——一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」,这是安全的常规情况(所有模型驱动的调用都如此)。因此它们保持普通 optional,与 `signal` 一致。 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml index 7fd27e6770..6c6b1d6b44 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.i18n.yaml @@ -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 -2026-07-26-subprocess-seam.md: 5cf0e596603b4cd3240e5d9a114d95f413c6410b -2026-07-26-subprocess-seam.zh.md: 31c37ae07f9456078673ccd005bd44c74ffa672e +2026-07-26-subprocess-seam.md: ad2f8522be51ba16b0df155aeb334a88f493890f +2026-07-26-subprocess-seam.zh.md: d9a0fb56b57b545dd1f94fde0cfb436d58fe00d4 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md index 5cf0e59660..ad2f8522be 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md @@ -13,7 +13,7 @@ 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 two-channel `DSH_*` 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 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-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. - **`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. @@ -29,7 +29,7 @@ Background-process lifetime moved from the executor to the subprocess service: t **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. +**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 ambient scrub (credential-shaped and `DSH_*` names) is a security/identity invariant and stays, but terminal friendliness is the bash tool's choice, expressed through the spec's explicit env where a caller's own entry still wins. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md index 31c37ae07f..d9a0fb56b5 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.zh.md @@ -13,7 +13,7 @@ Status: implemented 新的 `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 文件的尾部保留截断、带双通道 `DSH_*` 合并的凭据清除、进程组 kill 升级,以及会终止每个仍在运行的受管进程并等待其退出的 dispose。该实现没有任何配置;每项限制都随 spec 到达。终端相关的 `ENV_OVERRIDES`(`TERM=dumb` 等)并未迁移:那是 bash 工具的呈现策略,留在 `dsh-bash-local` 里,经普通 env 通道合并。 +- **`@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 通道合并。 - **`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 所有。 @@ -29,7 +29,7 @@ Status: implemented **改把 `run_in_background`/任务语义放进进程 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。进程 seam 位于 bash 执行器*之下*,而不是与任务注册表并列。 -**把 `ENV_OVERRIDES`(TERM=dumb、PAGER=cat 等)移入管理器。**否决:通用进程管理器不得把终端呈现策略强加给非终端消费方;凭据清除与 `DSH_*` 通道规则是安全与身份不变式,予以保留,但终端友好性是 bash 工具自己的选择,经普通 env 通道表达,而调用方的显式条目在该通道中依旧优先。 +**把 `ENV_OVERRIDES`(TERM=dumb、PAGER=cat 等)移入管理器。**否决:通用进程管理器不得把终端呈现策略强加给非终端消费方;对环境中凭据形态名称与 `DSH_*` 名称的清除是安全与身份不变式,予以保留,但终端友好性是 bash 工具自己的选择,经 spec 的显式 env 表达,而调用方自己的条目依旧优先。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml index 2e7b5b1d58..f756be1482 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.i18n.yaml @@ -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 -2026-07-10-agent-session-identity-and-log-location.md: a55bf276bff998a94f84ec1af078022e4881903c -2026-07-10-agent-session-identity-and-log-location.zh.md: 84b8b22187ccd1078ff13e39f4a345efbecfaeac +2026-07-10-agent-session-identity-and-log-location.md: 7b51ae41ac00c12a496940c891092580003646fa +2026-07-10-agent-session-identity-and-log-location.zh.md: 2574e1327f424069cdff68ef9b8de20c490077f0 diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md index a55bf276bf..7b51ae41ac 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md @@ -40,7 +40,7 @@ The registry rebuilds a trusted overlay for every foreground and background bash Session persistence remains the fact owner: JSONL does not depend on tool-bash or register shell variables itself, and hooks continue to consume `locate()` directly. Tool-bash is the translation layer from the persistence fact into a shell convention. Other plugins that need shell-visible facts depend on the registry and register their own keys; they do not modify `process.env`. -The bash seam exports `DSH_ENV_PREFIX` as the single namespace source and derives `DshEnvironmentKey` from its `typeof`. Tool-bash derives built-in names and model guidance from that constant, while executors use it for filtering and channel validation. The seam carries the managed overlay separately as `BashExecRequest.dshEnv` / `BashExecSpec.dshEnv`. Ordinary `env` remains the general in-process plugin surface used by hooks, but cannot contain managed keys; symmetrically, `dshEnv` cannot contain ordinary keys. The local executor rejects either wrong channel before spawn, removes every inherited ambient managed key, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments. +The bash seam exports `DSH_ENV_PREFIX` as the single namespace source and derives `DshEnvironmentKey` from its `typeof`. Tool-bash derives built-in names and model guidance from that constant, while executors use it for ambient filtering. The seam carries the managed overlay separately as `BashExecRequest.dshEnv` / `BashExecSpec.dshEnv`: ordinary `env` remains the general in-process plugin surface used by hooks, while `dshEnv` is typed to managed keys. The local executor removes every inherited ambient managed key, applies its ordinary scrub/terminal environment/explicit `env`, and finally merges the trusted `dshEnv` snapshot, so an `env` entry can never displace a managed value. This guarantees that a missing value means absent now rather than inherited from an outer or previous harness. The model-facing tool still ignores model-supplied `env`/`stdin` arguments. The bash tool description teaches only the durable convention: current harness environment facts are available through managed `$DSH_*` variables and may be inspected when needed. It does not enumerate persistence-specific keys or add a permanent system-prompt section. Tool schemas are already logged in request headers and tool output is logged as `tool/result`, so no new session event is required. @@ -82,6 +82,6 @@ A keyless full-loop integration drives the real agent loop, JSONL persistence, t ## Consequences -Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The complete `DSH_*` namespace inside these children is managed by the harness: ambient values are removed, current trusted values are re-added, and ordinary callers cannot use `env` to bypass ownership checks. +Every model bash child receives current Harness home and shell identity, and agent calls additionally receive stable session identity. JSONL-backed calls get an optional target path; non-file persistence omits it honestly. The managed `DSH_*` facts inside these children come from the harness: ambient values are removed, current trusted values are re-added last, and an ordinary caller's `env` entry cannot displace them. The namespace is discoverable but not secret. Paths can reveal configured roots, lazy targets can be absent or stale, and a command can override variables inside its own shell syntax. Consumers treat them as correlation and environment facts, verify transcript metadata when attribution matters, and rely on sandbox/filesystem policy rather than variable secrecy for authorization. diff --git a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md index 84b8b22187..2574e1327f 100644 --- a/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.zh.md @@ -40,7 +40,7 @@ interface SessionPersistence { 会话持久化仍然是事实所有者:JSONL 不依赖 tool-bash,也不会自行注册 shell 变量;钩子继续直接使用 `locate()`。tool-bash 是把持久化事实转换为 shell 约定的转换层。其他需要向 shell 公开事实的插件依赖该注册表,并注册各自的键;它们不修改 `process.env`。 -bash seam 导出 `DSH_ENV_PREFIX` 作为唯一的命名空间来源,并派生 `DshEnvironmentKey`,其来源是该常量的 `typeof`。tool-bash 从该常量派生内置名称与模型指引,执行器则使用该常量进行过滤和通道校验。seam 通过 `BashExecRequest.dshEnv`/`BashExecSpec.dshEnv` 单独传递受管理的覆盖层。普通 `env` 仍是钩子所用的通用进程内插件接口,但不能包含受管理的键;对称地,`dshEnv` 不能包含普通键。本地执行器会在 spawn 前拒绝任一错误通道,移除环境中继承的全部受管理键,依次应用普通清理、终端环境和显式 `env`,最后合并受信任的 `dshEnv` 快照。这保证了值缺失表示它当前确实不存在,而不是从外层或先前的 harness 继承而来。面向模型的工具仍忽略模型提供的 `env`/`stdin` 参数。 +bash seam 导出 `DSH_ENV_PREFIX` 作为唯一的命名空间来源,并派生 `DshEnvironmentKey`,其来源是该常量的 `typeof`。tool-bash 从该常量派生内置名称与模型指引,执行器则使用该常量过滤环境中已有的值。seam 通过 `BashExecRequest.dshEnv`/`BashExecSpec.dshEnv` 单独传递受管理的覆盖层:普通 `env` 仍是钩子所用的通用进程内插件接口,`dshEnv` 则以类型约束为受管理键。本地执行器移除环境中继承的全部受管理键,依次应用普通清理、终端环境和显式 `env`,最后合并受信任的 `dshEnv` 快照,因此 `env` 条目永远无法顶掉受管理的值。这保证了值缺失表示它当前确实不存在,而不是从外层或先前的 harness 继承而来。面向模型的工具仍忽略模型提供的 `env`/`stdin` 参数。 bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管理的 `$DSH_*` 变量提供,可以在需要时查看。它不会枚举持久化专用键,也不会添加永久的系统提示词章节。工具 schema 已记录在请求 header 中,工具输出则记录为 `tool/result`,因此无需新增会话事件。 @@ -82,6 +82,6 @@ bash 工具说明只讲解持久约定:当前 harness 环境事实通过受管 ## 影响 -每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。使用 JSONL 后端的调用可以获得可选的目标路径;非文件持久化会如实省略该值。这些子进程中的完整 `DSH_*` 命名空间由 harness 管理:系统移除环境中已有的受管理值、重新加入当前受信任的值,并禁止普通调用方通过 `env` 绕过所有权检查。 +每个面向模型的 bash 子进程都会收到当前 Harness home 和 shell 标识,关联 agent 的调用还会收到稳定的会话标识。使用 JSONL 后端的调用可以获得可选的目标路径;非文件持久化会如实省略该值。这些子进程中受管理的 `DSH_*` 事实来自 harness:系统移除环境中已有的受管理值、在最后重新加入当前受信任的值,普通调用方的 `env` 条目无法顶掉它们。 该命名空间可被发现,但并非秘密。路径可能泄露配置的根目录,延迟创建的目标也可能不存在或处于陈旧状态,而且命令可以在自己的 shell 语法中覆盖变量。消费方应把这些值视为关联信息和环境事实,在归属关系重要时校验 transcript 元数据,并依靠沙箱/文件系统策略而不是变量保密性来完成授权。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a0b43c245a..a030b45da2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1582,7 +1582,7 @@ abstract spawn(spec: SubprocessSpawnSpec): SubprocessHandle Types: [SubprocessHandle](../core-data-structures/subprocess.md) · [SubprocessSpawnSpec](../core-data-structures/subprocess.md) -Source: [`packages/subprocess/subprocess/src/index.ts:112`](../../packages/subprocess/subprocess/src/index.ts) +Source: [`packages/subprocess/subprocess/src/index.ts:89`](../../packages/subprocess/subprocess/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/bash.i18n.yaml b/docs/core-data-structures/bash.i18n.yaml index fba715d163..9d261fe939 100644 --- a/docs/core-data-structures/bash.i18n.yaml +++ b/docs/core-data-structures/bash.i18n.yaml @@ -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 -bash.md: 4ea0ace7e4af8cda3ff5bf1b4fc672f6425e5396 -bash.zh.md: 6642c85134748bceaa4a7783742a63da8b6acabd +bash.md: 3747244662301a256e12037ea67c21017b5ac2c5 +bash.zh.md: 9927aa8d51ee410d70bed7a2d00e40061b499e15 diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 4ea0ace7e4..3747244662 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -46,17 +46,18 @@ interface BashExecRequest { stdin?: string | undefined /** * Ordinary environment entries for the command, merged after the credential - * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it - * here. Set by in-process plugins (the hooks bridges set - * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool - * does not expose it as a parameter. + * scrub. Managed facts belong in {@link dshEnv}, which merges after this + * map, so an entry here can never displace one. Set by in-process plugins + * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the + * model-facing bash tool does not expose it as a parameter. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Executors 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 managed channel. + * Harness-owned `DSH_*` variables for this execution (typed to managed + * keys). Executors discard ambient `DSH_*` entries before merging this + * snapshot last, so an unavailable current fact cannot inherit a stale + * value from the harness process and a caller {@link env} entry cannot + * displace a managed one. */ dshEnv?: DshEnvironment | undefined /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ @@ -85,12 +86,12 @@ interface BashExecSpec { stdin?: string | undefined /** * Ordinary environment entries carried through from - * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * {@link BashExecRequest.env}; {@link dshEnv} still merges after them. * OPTIONAL on the spec for the same reason as `stdin`: absent means no * ordinary extra environment. */ env?: Record | undefined - /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */ dshEnv?: DshEnvironment | undefined /** Resolved sandbox policy; ignored by executors that do not confine. */ sandboxPolicy: SandboxExecutionPolicy | undefined diff --git a/docs/core-data-structures/bash.zh.md b/docs/core-data-structures/bash.zh.md index 6642c85134..9927aa8d51 100644 --- a/docs/core-data-structures/bash.zh.md +++ b/docs/core-data-structures/bash.zh.md @@ -46,17 +46,18 @@ interface BashExecRequest { stdin?: string | undefined /** * Ordinary environment entries for the command, merged after the credential - * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it - * here. Set by in-process plugins (the hooks bridges set - * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool - * does not expose it as a parameter. + * scrub. Managed facts belong in {@link dshEnv}, which merges after this + * map, so an entry here can never displace one. Set by in-process plugins + * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the + * model-facing bash tool does not expose it as a parameter. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Executors 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 managed channel. + * Harness-owned `DSH_*` variables for this execution (typed to managed + * keys). Executors discard ambient `DSH_*` entries before merging this + * snapshot last, so an unavailable current fact cannot inherit a stale + * value from the harness process and a caller {@link env} entry cannot + * displace a managed one. */ dshEnv?: DshEnvironment | undefined /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ @@ -85,12 +86,12 @@ interface BashExecSpec { stdin?: string | undefined /** * Ordinary environment entries carried through from - * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * {@link BashExecRequest.env}; {@link dshEnv} still merges after them. * OPTIONAL on the spec for the same reason as `stdin`: absent means no * ordinary extra environment. */ env?: Record | undefined - /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */ dshEnv?: DshEnvironment | undefined /** Resolved sandbox policy; ignored by executors that do not confine. */ sandboxPolicy: SandboxExecutionPolicy | undefined diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index 0527cf34a3..17a21e9ac3 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/subprocess.i18n.yaml @@ -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 -subprocess.md: cdd4507c7d37f47ca243ddf38114f5aa6b6f3ad1 -subprocess.zh.md: 78325c3255c42ed591bbd98fdbdb4fdfcba48202 +subprocess.md: db4ab49ea953bfcf9ae59ede3187795eb71f0268 +subprocess.zh.md: 3f0a6c798056865ead1352f87328fb9dad6d5c0f diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index cdd4507c7d..db4ab49ea9 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -8,7 +8,7 @@ Source: [`packages/subprocess/subprocess/src/types.ts`](../../packages/subproces ## Managed environment namespace and captured output -`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before merging the caller's snapshot, and each collected stream reports its truncation and spill-recovery state through `CollectedOutput`. +`DSH_*` variables are Harness-owned child-process facts; implementations discard ambient `DSH_*` names before the caller's explicit `env` merges, so a current fact arrives only as a deliberate entry, and each collected stream reports its truncation and spill-recovery state through `CollectedOutput`. ```ts type-equiv /** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ @@ -114,19 +114,14 @@ interface SubprocessSpawnSpec { */ signal?: AbortSignal | undefined /** - * Ordinary environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`). `DSH_*` names are rejected and - * belong in {@link dshEnv}; a deliberately forwarded credential-shaped - * entry survives because this layer merges after the scrub. + * Explicit environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`), with no namespace validation: + * every entry is a deliberate caller opt-in, so a forwarded + * credential-shaped entry or a current `DSH_*` fact survives precisely + * because this layer merges after the scrub that drops its ambient + * namesake. */ env?: Record | undefined - /** - * Harness-owned `DSH_*` variables for this execution. The scrubbed base has - * already discarded ambient `DSH_*` entries, so an unavailable current fact - * cannot inherit a stale value from the harness process; non-`DSH_*` names - * on this channel are rejected. - */ - dshEnv?: DshEnvironment | undefined } ``` diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 78325c3255..3f0a6c7980 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -8,7 +8,7 @@ ## 受管环境命名空间与捕获的输出 -`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方快照之前丢弃环境中已有的 `DSH_*` 名称,每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 +`DSH_*` 变量是归 Harness 所有的子进程事实;实现会在合并调用方显式 `env` 之前丢弃环境中已有的 `DSH_*` 名称,因此当前事实只会以有意提供的条目形式到达,每条被收集的流都通过 `CollectedOutput` 报告自身的截断与 spill 恢复状态。 ```ts type-equiv /** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ @@ -114,19 +114,14 @@ interface SubprocessSpawnSpec { */ signal?: AbortSignal | undefined /** - * Ordinary environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`). `DSH_*` names are rejected and - * belong in {@link dshEnv}; a deliberately forwarded credential-shaped - * entry survives because this layer merges after the scrub. + * Explicit environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`), with no namespace validation: + * every entry is a deliberate caller opt-in, so a forwarded + * credential-shaped entry or a current `DSH_*` fact survives precisely + * because this layer merges after the scrub that drops its ambient + * namesake. */ env?: Record | undefined - /** - * Harness-owned `DSH_*` variables for this execution. The scrubbed base has - * already discarded ambient `DSH_*` entries, so an unavailable current fact - * cannot inherit a stale value from the harness process; non-`DSH_*` names - * on this channel are rejected. - */ - dshEnv?: DshEnvironment | undefined } ``` diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index f93c17e2ed..0f5a1b4e4d 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -19,8 +19,8 @@ import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' * Model-friendly environment overrides: disable colors, pagers, and * interactive terminal features that would garble tool output (the same set * Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy — - * merged into the ordinary env channel, so a trusted caller's own entry still - * wins; the subprocess service applies its credential scrub independently. + * merged first into the spawn's explicit env, so a trusted caller's own entry + * still wins; the subprocess service applies its credential scrub independently. */ export const ENV_OVERRIDES = { NO_COLOR: '1', @@ -152,8 +152,10 @@ export class LocalBashExecutor extends BashExecutor { }, graceMs: this.config.graceMs, signal, - env: { ...ENV_OVERRIDES, ...spec.env }, - dshEnv: spec.dshEnv, + // One explicit env map for the seam, layered so the trusted dshEnv + // snapshot beats both the caller's env and the terminal overrides; the + // subprocess service merges the whole map after its ambient scrub. + env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }, } } diff --git a/packages/bash/bash/README.i18n.yaml b/packages/bash/bash/README.i18n.yaml index f32efb60a0..520828acde 100644 --- a/packages/bash/bash/README.i18n.yaml +++ b/packages/bash/bash/README.i18n.yaml @@ -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 -README.md: b4ee66a1fa2696254a1f2f411b7db5d3190f8370 -README.zh.md: 151d4bd7ab257234584b9008c96e6356d7e39351 +README.md: d7bf746969f52000fe298b65b995b7c631d8001c +README.zh.md: 14476b770397e4ef850c7c867e3058e25085c339 diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index b4ee66a1fa..d7bf746969 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -33,7 +33,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). -`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). ## Model Experience diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md index 151d4bd7ab..14476b7703 100644 --- a/packages/bash/bash/README.zh.md +++ b/packages/bash/bash/README.zh.md @@ -33,7 +33,7 @@ 每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。 -`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,拒绝普通 `env` 中的这些名称,再合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 ## 模型体验 diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index ea153605af..6fdd8b36c7 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -60,17 +60,18 @@ export interface BashExecRequest { stdin?: string | undefined /** * Ordinary environment entries for the command, merged after the credential - * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it - * here. Set by in-process plugins (the hooks bridges set - * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool - * does not expose it as a parameter. + * scrub. Managed facts belong in {@link dshEnv}, which merges after this + * map, so an entry here can never displace one. Set by in-process plugins + * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the + * model-facing bash tool does not expose it as a parameter. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Executors 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 managed channel. + * Harness-owned `DSH_*` variables for this execution (typed to managed + * keys). Executors discard ambient `DSH_*` entries before merging this + * snapshot last, so an unavailable current fact cannot inherit a stale + * value from the harness process and a caller {@link env} entry cannot + * displace a managed one. */ dshEnv?: DshEnvironment | undefined /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ @@ -97,12 +98,12 @@ export interface BashExecSpec { stdin?: string | undefined /** * Ordinary environment entries carried through from - * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * {@link BashExecRequest.env}; {@link dshEnv} still merges after them. * OPTIONAL on the spec for the same reason as `stdin`: absent means no * ordinary extra environment. */ env?: Record | undefined - /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */ dshEnv?: DshEnvironment | undefined /** Resolved sandbox policy; ignored by executors that do not confine. */ sandboxPolicy: SandboxExecutionPolicy | undefined diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index cf879df592..5853c92c0b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2247,7 +2247,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubprocessSpawnSpec', - declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n}', + declaration: 'export interface SubprocessSpawnSpec {\n argv: readonly string[];\n cwd: string;\n stdio: SubprocessStdio;\n graceMs: number;\n signal?: AbortSignal | undefined;\n env?: Record | undefined;\n}', }, { name: 'SubprocessStdinMode', diff --git a/packages/lsp/lsp-local/README.i18n.yaml b/packages/lsp/lsp-local/README.i18n.yaml index 41bbe5f7c6..a459427a78 100644 --- a/packages/lsp/lsp-local/README.i18n.yaml +++ b/packages/lsp/lsp-local/README.i18n.yaml @@ -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 -README.md: 3c70977b6ea783d5aa1766709d2a507e08b8ceca -README.zh.md: d24100c5f850172845a24acadcbd2d0207b64859 +README.md: 85254eea2bb74df277be6fd5de1529b5da4ea178 +README.zh.md: 290601d455ba2012d0c1f5c97503ae87304881cb diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 3c70977b6e..85254eea2b 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -23,7 +23,7 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v |---|---|---| | `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. | | `args` | `[]` | Arguments passed to the executable. | -| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded); `DSH_*` entries ride the subprocess seam's managed channel. | +| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded); an explicit `DSH_*` entry merges after the seam's scrub of ambient ones. | | `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). | | `initializationOptions` | `null` | Static `initialize` options forwarded to the server. | | `configuration` | `null` | Static answer to every `workspace/configuration` item. | diff --git a/packages/lsp/lsp-local/README.zh.md b/packages/lsp/lsp-local/README.zh.md index d24100c5f8..290601d455 100644 --- a/packages/lsp/lsp-local/README.zh.md +++ b/packages/lsp/lsp-local/README.zh.md @@ -23,7 +23,7 @@ Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出) |---|---|---| | `command` | (必填) | 要 spawn 的可执行文件:绝对路径,或在加载时从子进程 PATH 解析。不使用 shell 启动。 | | `args` | `[]` | 传给可执行文件的参数。 | -| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`SECRET`/`TOKEN` 的变量不会转发);`DSH_*` 条目走 subprocess seam 的受管通道。 | +| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`SECRET`/`TOKEN` 的变量不会转发);显式 `DSH_*` 条目在 seam 清除环境中同名值之后合并。 | | `extensionToLanguage` | (必填) | 小写、以点开头的扩展名 → LSP language id(例如 `{ '.ts': 'typescript' }`)。 | | `initializationOptions` | `null` | 转发给服务器的静态 `initialize` 选项。 | | `configuration` | `null` | 每个 `workspace/configuration` 配置项的静态答案。 | diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 74f68288e6..91107bfbeb 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -11,7 +11,6 @@ */ import type { Writable } from 'node:stream' -import { splitEnvChannels } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { encodeMessage, MessageDecoder } from './framing.ts' @@ -99,9 +98,10 @@ export class LspConnection { stderr: { maxBytes: spec.maxStderrBytes }, }, graceMs: spec.pipeDrainGraceMs, - // spec.env mixes the scrubbed base with explicit config entries; a - // configured DSH_* fact takes the managed channel the seam reserves. - ...splitEnvChannels(spec.env), + // spec.env mixes the scrubbed base with explicit config entries; the + // seam merges the whole map after its own ambient scrub, so a + // configured DSH_* fact reaches the child. + env: spec.env, }) /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ if (this.handle.stdin === undefined || this.handle.stdout === undefined) { diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index c717e3c886..076abb43e6 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -51,10 +51,10 @@ describe('LspConnection', () => { expect(conn.pid).toBeGreaterThan(0) }) - it('routes explicit DSH_* env entries onto the managed channel', async () => { - // A configured DSH_* fact must reach the child: the ordinary channel - // rejects the reserved namespace, so the connection's spawn must split it - // onto dshEnv. The fixture echoes the named variable back as hover text. + it('forwards explicit DSH_* env entries to the child', async () => { + // A configured DSH_* fact must reach the child: the seam scrubs only the + // ambient namespace, and the explicit entry merges after that scrub. The + // fixture echoes the named variable back as hover text. const conn = connect({ LSP_FAKE_ECHO_ENV: 'DSH_LSP_TEST_FACT', DSH_LSP_TEST_FACT: 'managed' }) await conn.request('initialize', { capabilities: {} }) expect(await conn.request('textDocument/hover', {})).toEqual({ contents: 'managed' }) diff --git a/packages/subagent/subagent-acp/README.i18n.yaml b/packages/subagent/subagent-acp/README.i18n.yaml index 61f41f5665..13589b4dd0 100644 --- a/packages/subagent/subagent-acp/README.i18n.yaml +++ b/packages/subagent/subagent-acp/README.i18n.yaml @@ -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 -README.md: 0c21e179bd5ca405057b682786c41c8447a78317 -README.zh.md: b349443cfcda80caa9b06cfa1cf80f6e780a98be +README.md: 044a4c5a71d32224262acef9e2c849f375198c2c +README.zh.md: 918df12ea0ea50e6f59b144e74601ff6beacbe86 diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 0c21e179bd..044a4c5a71 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -57,7 +57,7 @@ ACP advertises no start-time capabilities because this process cannot enforce th ## Process boundary -The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives; `DSH_*` entries such as `DSH_PERMISSION_MODE` ride the seam's managed channel, since the scrubbed base reserves that namespace for current deployment facts), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. +The child spawns through the [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam: credential-shaped ambient variables and ambient `DSH_*` names are removed by the shared scrub, then explicit `config.env` values merge after it (an intended `DEEPSEEK_API_KEY` survives, and a `DSH_*` deployment fact such as `DSH_PERMISSION_MODE` reaches the child the same way — the scrub drops only its stale ambient namesake), stderr is inherited to the parent's own stream, and disposal runs the seam's cooperative stdin-EOF→SIGTERM→SIGKILL ladder with this plugin's configured graces. The ACP wire is the real serialization boundary; same-process subagent values are not defensively cloned. The package has no default export. Cordis loader unwrapping would otherwise hide the named `inject` metadata; see [postmortem 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). diff --git a/packages/subagent/subagent-acp/README.zh.md b/packages/subagent/subagent-acp/README.zh.md index b349443cfc..918df12ea0 100644 --- a/packages/subagent/subagent-acp/README.zh.md +++ b/packages/subagent/subagent-acp/README.zh.md @@ -57,7 +57,7 @@ ACP 不声明任何启动时能力,因为当前进程无法强制执行远程 ## 进程边界 -子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除名称形似凭据的环境变量,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来;`DSH_PERMISSION_MODE` 这类 `DSH_*` 条目走该 seam 的受管通道,因为清除后的基底把这一命名空间保留给当前部署事实),stderr 以 inherit 方式直通父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 +子进程经由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) seam spawn:共享的凭据清除先移除名称形似凭据的环境变量和环境中已有的 `DSH_*` 名称,显式 `config.env` 值在清除之后合并(有意转发的 `DEEPSEEK_API_KEY` 会保留下来,`DSH_PERMISSION_MODE` 这类 `DSH_*` 部署事实也以同样的方式到达子进程——清除只丢弃其陈旧的同名环境值),stderr 以 inherit 方式直通父进程自身的流,dispose 则以本插件配置的宽限期运行该 seam 的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。ACP 协议是真正的序列化边界;同进程 subagent 值不会为防御目的而克隆。 本包没有默认导出。否则 Cordis loader 的解包会隐藏具名 `inject` 元数据;见[事故复盘 0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)。 diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 07358f230c..66584b6fb7 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -25,7 +25,6 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' -import { splitEnvChannels } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** Fixed response to child permission requests: reject by default, or select the first allow option. */ @@ -51,8 +50,8 @@ export interface AcpRunSpec { * parent env. A value here is forwarded even if its name matches the * credential-scrub pattern (an explicit opt-in for the child's own creds). * Explicit `DSH_*` entries are deployment-owned facts for the child harness - * (e.g. `DSH_PERMISSION_MODE`) and ride the seam's managed channel, which - * the scrubbed base reserves for current values. + * (e.g. `DSH_PERMISSION_MODE`); they simply merge after the scrub that + * dropped their stale ambient namesakes. */ env: Record /** @@ -169,15 +168,14 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe const id = SessionId(randomUUID()) // Keep diagnostics on parent stderr ('inherit'); only ACP output contributes - // to the result. The seam's scrub drops ambient credentials while spec.env - // (the child's own key) merges after it; explicit DSH_* entries ride the - // managed channel via the seam's split. + // to the result. The seam's scrub drops ambient credentials and DSH_* names + // while spec.env (the child's own key, its deployment facts) merges after it. const child = spec.spawn({ argv: [spec.command, ...spec.args], cwd: spec.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs: spec.disposeGraceMs, - ...splitEnvChannels(spec.env), + env: spec.env, }) /* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */ if (child.stdin === undefined || child.stdout === undefined) { diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 7c10463000..a6a7e5f0cb 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -120,11 +120,10 @@ describe('child env layering (through the subprocess seam)', () => { } }) - it('routes explicit DSH_* config entries onto the managed channel', async () => { + it('forwards explicit DSH_* config entries to the child', async () => { // A deployment sets child-harness facts like DSH_PERMISSION_MODE in - // config.env; the run must split them onto the seam's managed channel - // (the ordinary channel rejects the reserved namespace) and the child - // must still see the value. + // config.env; the seam's scrub drops only the AMBIENT namesakes, so the + // explicit entry merges after it and the child must see the value. const ctx = await setup({ MOCK_ECHO_ENV: 'DSH_ACP_TEST_FACT', DSH_ACP_TEST_FACT: 'managed' }) const parent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }) diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 2e8c1fdae4..4137474562 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -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 -README.md: 08cc2ce7d92569222b99992d0f4c43551a2c9623 -README.zh.md: da230ba37d406a6ad4ec669ceead45f2c2dd7069 +README.md: a62929e8ee1c1c852d99d9569f4118bc751e8150 +README.zh.md: 99febdbe54988f54b74dbc2c80251ec1e9e671ef diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 08cc2ce7d9..a62929e8ee 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -8,7 +8,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README - **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. -- **Credential scrub + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +- **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index da230ba37d..99febdbe54 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -8,7 +8,7 @@ - **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。 -- **凭据清除 + 受管 `DSH_*` 合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的普通 `env` 在清除后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止陈旧的嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +- **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **先终止再等待退出的 dispose**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 8716f50b0c..1df197e626 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -16,10 +16,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as sleepMs } from 'node:timers/promises' import { deadline } from '@deepseek-ai/dsh-timeout' -import { DSH_ENV_PREFIX, scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type { CollectedOutput, - DshEnvironment, SubprocessCollect, SubprocessDisposeGraces, SubprocessHandle, @@ -29,28 +28,14 @@ import type { } from '@deepseek-ai/dsh-subprocess' /** - * Build a child environment from the scrubbed parent base, ordinary caller - * entries, and a managed `DSH_*` snapshot. Ordinary and managed entries - * reject the other channel's namespace before `dshEnv` merges last. - * @param extra - caller entries; `DSH_*` names are rejected. - * @param dshEnv - managed entries; non-`DSH_*` names are rejected. + * Build a child environment: explicit caller entries merge after the scrubbed + * parent base, so a deliberately supplied credential or current `DSH_*` fact + * wins over the scrub that dropped its ambient namesake. + * @param extra - explicit caller entries, merged verbatim after the scrub. * @returns the environment to hand to `spawn` for the child process. */ -export function childEnv( - extra?: Readonly>, - dshEnv?: DshEnvironment, -): NodeJS.ProcessEnv { - for (const key of Object.keys(extra ?? {})) { - if (key.startsWith(DSH_ENV_PREFIX)) { - throw new Error(`ordinary child env cannot set reserved variable "${key}"; use dshEnv`) - } - } - for (const key of Object.keys(dshEnv ?? {})) { - if (!key.startsWith(DSH_ENV_PREFIX)) { - throw new Error(`managed child env cannot set ordinary variable "${key}"; use env`) - } - } - return { ...scrubbedParentEnv(), ...extra, ...dshEnv } +export function childEnv(extra?: Readonly>): NodeJS.ProcessEnv { + return { ...scrubbedParentEnv(), ...extra } } /** Injectable knobs so tests can exercise spill and platform behavior deterministically. */ @@ -339,7 +324,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const errMode = spec.stdio.stderr const stdinMode = spec.stdio.stdin - const env = childEnv(spec.env, spec.dshEnv) + const env = childEnv(spec.env) const child = spawn(program, args, { cwd: spec.cwd, env, diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 4b3e16fd5a..745a9231dc 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -2,7 +2,6 @@ import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import type { DshEnvironment } from '@deepseek-ai/dsh-subprocess' import { killGroup, OutputCollector, spawnSubprocess, taskkillProcessTree } from '../src/spawn.ts' import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' @@ -840,11 +839,13 @@ describe('environment and spill-file hardening', () => { } }) - it('injects only the current trusted DSH environment after scrubbing ambient values', async () => { + it('forwards explicit DSH_* env entries while scrubbing ambient ones', async () => { + // Both facts through one explicit map: the ambient DSH_STALE is dropped by + // the scrub, and the deliberately supplied current values merge after it. process.env.DSH_STALE = 'old-value' try { const result = await finish(spawnSubprocess(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { - dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' }, + env: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' }, }))) expect(result.stdout.text.trim()).toBe('[absent|1|current-session]') } finally { @@ -852,17 +853,6 @@ describe('environment and spill-file hardening', () => { } }) - it('rejects DSH variables on the ordinary env channel', () => { - expect(() => spawnSubprocess(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } }))) - .toThrow(/DSH_WRONG_CHANNEL.*dshEnv/) - }) - - it('rejects ordinary variables on the managed env channel', () => { - const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment - expect(() => spawnSubprocess(spec('true', { dshEnv: invalid }))) - .toThrow(/managed child env.*PATH.*use env/) - }) - it('creates spill files with owner-only permissions and random names', async () => { const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 31f0fc4e84..4dc0bdf5cc 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -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 -README.md: 2cb7a5ebce404c440e625dea844ed28ceadb06f3 -README.zh.md: a3211834e065359e813e8148a8f6a6a15f8f89b6 +README.md: 65c2be2b77fbd7105208b86fa1b98a3c44013f94 +README.zh.md: 98a0315914746a94350ceec53e4daff886054e64 diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 2cb7a5ebce..65c2be2b77 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -10,7 +10,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. - Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification). -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel; `splitEnvChannels()` partitions a consumer config's single mixed env map onto those two channels (lsp-local servers and the ACP backend expose one map, and a configured `DSH_*` fact must ride the managed channel the ordinary one rejects). Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, and the spec's explicit `env` merges after the scrub with no namespace validation — a deliberately forwarded credential or a current `DSH_*` fact survives precisely because it is an explicit caller opt-in, while the stale ambient namesake never reaches the child. Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. - Disposal of the service terminates all still-running managed processes and awaits their exit. See the [subprocess data-structure catalog](../../../docs/core-data-structures/subprocess.md) and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md). diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index a3211834e0..98a0315914 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -10,7 +10,7 @@ - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 形状:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 - 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。 -- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实;`splitEnvChannels()` 把消费方配置中单一的混合 env 映射按这两条通道切分(lsp-local 的服务器配置与 ACP 后端只暴露一个映射,而配置的 `DSH_*` 事实必须走受管通道,普通通道会拒绝它)。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入凭据清除函数。 +- `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,spec 的显式 `env` 在清除之后合并且不做命名空间校验——有意转发的凭据或当前 `DSH_*` 事实之所以能保留下来,正因为它是调用方的显式选择,而陈旧的同名环境值永远到不了子进程。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入凭据清除函数。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 参见[进程管理器数据结构目录](../../../docs/core-data-structures/subprocess.md)与 [seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md)。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index e4cfaae901..8c7d6797e6 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -12,7 +12,6 @@ import { Context, Service } from 'cordis' import { DSH_ENV_PREFIX } from './types.ts' -import type { DshEnvironment, DshEnvironmentKey } from './types.ts' import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' export { DSH_ENV_PREFIX } from './types.ts' @@ -46,11 +45,10 @@ export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i * The ambient parent environment minus credential-shaped names and minus all * `DSH_*` names — the canonical base every harness child starts from. `PATH`, * `HOME`, locale, and proxy variables survive, so child CLIs run normally; - * harness identity never leaks implicitly (a child that needs current `DSH_*` - * facts receives them through {@link SubprocessSpawnSpec.dshEnv}, and a - * deliberately forwarded credential goes through an explicit env layer, which - * merges after this scrub). Exported as a plain function so spawners that - * cannot route through the service (node-pty backends, SDK-managed + * harness identity never leaks implicitly (a deliberately forwarded + * credential or current `DSH_*` fact goes through the spec's explicit `env`, + * which merges after this scrub). Exported as a plain function so spawners + * that cannot route through the service (node-pty backends, SDK-managed * transports) share the one scrub definition. * @returns a fresh environment object safe to hand to a child spawn. */ @@ -62,27 +60,6 @@ export function scrubbedParentEnv(): Record { return env } -/** - * Partition one mixed explicit-env map onto the spec's two channels: `DSH_*` - * names are deployment-owned facts for the child and take the managed - * {@link SubprocessSpawnSpec.dshEnv} channel (the ordinary channel rejects the - * reserved namespace), everything else stays ordinary `env`. For consumers - * whose configs expose a single env map (lsp-local servers, the ACP backend) - * rather than two channel-shaped fields. - * @param env - explicit entries from a consumer's config, both namespaces mixed. - * @returns the two spec channels, each safe for its validator. - */ -export function splitEnvChannels(env: Readonly>): { env: Record; dshEnv: DshEnvironment } { - const ordinary: Record = {} - const managed: Record = {} - const isDshKey = (key: string): key is DshEnvironmentKey => key.startsWith(DSH_ENV_PREFIX) - for (const [key, value] of Object.entries(env)) { - if (isDshKey(key)) managed[key] = value - else ordinary[key] = value - } - return { env: ordinary, dshEnv: managed } -} - declare module 'cordis' { interface Context { subprocess: SubprocessService diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 4c667919c7..c936b7bd78 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -93,19 +93,14 @@ export interface SubprocessSpawnSpec { */ signal?: AbortSignal | undefined /** - * Ordinary environment entries merged onto the implementation's scrubbed - * parent base (see `scrubbedParentEnv`). `DSH_*` names are rejected and - * belong in {@link dshEnv}; a deliberately forwarded credential-shaped - * entry survives because this layer merges after the scrub. + * Explicit environment entries merged onto the implementation's scrubbed + * parent base (see `scrubbedParentEnv`), with no namespace validation: + * every entry is a deliberate caller opt-in, so a forwarded + * credential-shaped entry or a current `DSH_*` fact survives precisely + * because this layer merges after the scrub that drops its ambient + * namesake. */ env?: Record | undefined - /** - * Harness-owned `DSH_*` variables for this execution. The scrubbed base has - * already discarded ambient `DSH_*` entries, so an unavailable current fact - * cannot inherit a stale value from the harness process; non-`DSH_*` names - * on this channel are rejected. - */ - dshEnv?: DshEnvironment | undefined } /** diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 9fb68f403f..d033e24a31 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { scrubbedParentEnv, splitEnvChannels, SubprocessService } from '@deepseek-ai/dsh-subprocess' +import { scrubbedParentEnv, SubprocessService } from '@deepseek-ai/dsh-subprocess' import type { SubprocessDisposeGraces, SubprocessHandle, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' /** @@ -72,10 +72,4 @@ describe('SubprocessService seam', () => { delete process.env.SCRUB_PROBE_PLAIN } }) - - it('splitEnvChannels partitions a mixed map onto the two spec channels', () => { - const { env, dshEnv } = splitEnvChannels({ DSH_FACT: 'managed', PLAIN: 'ordinary', DEEPSEEK_API_KEY: 'explicit' }) - expect(env).toEqual({ PLAIN: 'ordinary', DEEPSEEK_API_KEY: 'explicit' }) - expect(dshEnv).toEqual({ DSH_FACT: 'managed' }) - }) }) From f81fcccd9376ca06de0430fb16992c8c832a0845 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:41:04 +0800 Subject: [PATCH 42/70] refactor(subprocess): keep terminate() as the seam's only termination verb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete kill(signal?) from SubprocessHandle: consumers stop a process only through terminate()'s tree-scoped SIGTERM→graceMs→SIGKILL escalation (idempotent, also driven by the spec's abort signal, a no-op once the tree is gone). The single-signal verb had exactly one consumer family — lsp-local — and what it bought there was a private re-implementation of the same escalation. The internal kill closure stays in spawn.ts as the dispose ladder's tier primitive; terminate() now routes through it too. lsp-local collapses onto the seam's escalation: - LspConnection replaces its terminate()/kill() pair with one terminate() that delegates to handle.terminate(). Behavior change: the framing-failure path terminates instead of instant SIGKILL, so a misbehaving server now gets SIGTERM plus the killGraceMs window to flush before SIGKILL. - ConnectionSpec.pipeDrainGraceMs becomes killGraceMs: one grace, the spawn spec's graceMs, drives both the escalation window and post-exit pipe draining (the provider already passed killGraceMs for it). - LspInstance.forceTerminate() drops its hand-rolled bounded first wait (LSP_KILL_GRACE) and escalateProcessTree (deleted with its export and unit test): the seam's escalation already commits to SIGKILL after killGraceMs, so only the unbounded quiescence awaits stay load-bearing. Tests: kill()-shaped spawn specs become terminate()-shaped or fold into the terminate() suites (group-wide delivery; the settled no-op case was already pinned by 'terminate() after the tree died'); tree-survivor coverage is intact. A stderr-'inherit' disposition test completes the stdout/stderr symmetry so the scoped subprocess+lsp coverage gate stands alone instead of leaning on subagent-acp's cross-package runs. Docs: SubprocessHandle type-equiv block, seam/impl/group READMEs, and the consumer-migration Agent Note lose the kill(signal?) vocabulary (zh pairs re-recorded); cordis api/services catalogs regenerated. --- ...26-subprocess-consumer-migration.i18n.yaml | 4 +- ...026-07-26-subprocess-consumer-migration.md | 4 +- ...-07-26-subprocess-consumer-migration.zh.md | 4 +- docs/cordis-catalog/services.md | 2 +- .../core-data-structures/subprocess.i18n.yaml | 4 +- docs/core-data-structures/subprocess.md | 14 ++---- docs/core-data-structures/subprocess.zh.md | 14 ++---- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/lsp/lsp-local/src/connection.ts | 24 ++++------ packages/lsp/lsp-local/src/index.ts | 2 - packages/lsp/lsp-local/src/instance.ts | 26 +++------- .../lsp/lsp-local/tests/connection.spec.ts | 12 ++--- packages/lsp/lsp-local/tests/instance.spec.ts | 13 +---- packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.md | 2 +- packages/subprocess/README.zh.md | 2 +- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess/subprocess-local/src/spawn.ts | 24 +++++----- .../subprocess-local/tests/spawn.spec.ts | 47 +++++++------------ .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 2 +- packages/subprocess/subprocess/README.zh.md | 2 +- packages/subprocess/subprocess/src/index.ts | 8 ++-- packages/subprocess/subprocess/src/types.ts | 12 ++--- .../subprocess/tests/service.spec.ts | 2 - 27 files changed, 87 insertions(+), 155 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml index 4326462cbf..7bfb3b6a74 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.i18n.yaml @@ -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 -2026-07-26-subprocess-consumer-migration.md: 805f27ba2e72a7f29d1b32053add95b8c33a62e2 -2026-07-26-subprocess-consumer-migration.zh.md: 41bdf04bc03517cf9fe10a61a565e442ecee5520 +2026-07-26-subprocess-consumer-migration.md: 9353e515e4d700e59bc771b5e38648594e58466e +2026-07-26-subprocess-consumer-migration.zh.md: fa7d8dfdbda6ddbf30e4799d130a71e1a476ab42 diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md index 805f27ba2e..9353e515e4 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.md @@ -14,7 +14,7 @@ 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, split Node-style**: `kill(signal?)` sends one signal and is a no-op after settlement; `terminate()` owns the SIGTERM→grace→SIGKILL escalation (and serves the spec's abort signal); `waitForExit()` polls tree liveness (POSIX group probe; direct-child boundary on Windows); `dispose(graces)` is the cooperative stdin-EOF→SIGTERM→SIGKILL ladder absorbed from `subagent-subprocess`, memoized per handle. Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. +- **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); `dispose(graces)` is the cooperative stdin-EOF→SIGTERM→SIGKILL ladder absorbed from `subagent-subprocess`, memoized per handle. Windows tree termination (`taskkill /T`, injectable) moved in from lsp-local, so tree semantics are platform-correct for every consumer. - **One scrub definition**: `scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` live on the seam. Spawners that cannot route the spawn itself through the service — pty-local (node-pty owns the fork) and mcp-client (the MCP SDK owns the transport spawn) — import the function, so environment policy is single-sourced even where process ownership is not; 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 `handle.dispose` 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). @@ -35,4 +35,4 @@ Compositions mounting lsp-local or subagent-acp now load `dsh-subprocess-local` Bought: one implementation of tree signalling, escalation, the dispose ladder, 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. -Cost: the seam is wider — three stdio modes and four termination verbs instead of one of each — 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/test-support spawns remain outside the service by ownership, with the scrub as the shared floor. +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/test-support spawns remain outside the service by ownership, with the scrub as the shared floor. diff --git a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md index 41bdf04bc0..fa7d8dfdbd 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-subprocess-consumer-migration.zh.md @@ -14,7 +14,7 @@ Status: implemented - **按流划分的 stdio 处置方式(disposition)**,位于 `SubprocessSpawnSpec` 上:`'pipe'`(原始的 `Readable`/`Writable`,供消费方自有的协议分帧使用)、`'inherit'`(诊断输出直通父进程的流),以及收集模式(collect)`{ maxBytes, spill? }`——即最初的有界尾部保留形状,只是 spill 文件改为可选,使诊断尾部(例如语言服务器的 stderr)无需落盘即可缓冲。stdin 则为 `'ignore'`、`'pipe'` 或 `{ data }`(写完即关闭的批量形式)。 - **`SubprocessOutcome` 只承载退出事实**(Node close 事件的词汇);收集到的输出在结算后仍可经 `handle.collected` 读取(spill 文件描述符在结算边界封存),因此批量与流式调用方共用一条访问路径,也没有任何内容被复制进这份结果。 -- **以进程树为范围的终止,按 Node 风格拆分**:`kill(signal?)` 只发送一个信号,结算后为空操作;`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(并承接 spec 的 abort 信号);`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界);`dispose(graces)` 是从 `subagent-subprocess` 吸收来的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯,按句柄 memoize 化。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。 +- **以进程树为范围的终止,集中在一个动词后面**:`terminate()` 拥有 SIGTERM→宽限期→SIGKILL 升级(也承接 spec 的 abort 信号,进程树消亡后为空操作)——句柄不暴露单信号的 `kill(signal?)`,因此消费方无法跳过宽限窗口;`waitForExit()` 轮询进程树存活状态(POSIX 进程组探测;Windows 上以直接子进程为界);`dispose(graces)` 是从 `subagent-subprocess` 吸收来的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯,按句柄 memoize 化。Windows 进程树终止(`taskkill /T`,可注入)自 lsp-local 迁入,因此每个消费方拿到的进程树语义在各平台上都正确。 - **凭据清除只有一份定义**:`scrubbedParentEnv()`/`SENSITIVE_ENV_PATTERN` 定义在 seam 上。无法把 spawn 本身路由到该服务的调用点——pty-local(node-pty 拥有 fork)与 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 就是携带插件所配置宽限期的 `handle.dispose` 调用)。**`dsh-subagent-subprocess` 已删除**——dispose 阶梯与凭据清除归 seam 所有;无人使用的隔离配置目录辅助函数随之消亡(其消费方本就不存在)。 @@ -35,4 +35,4 @@ Status: implemented 换来的是:进程树信号发送、升级、dispose 阶梯、有界收集与凭据清除各自只剩一份实现,且只在 `dsh-subprocess-local` 的测试套件中测试一次(其中包括 lsp-local 的私有副本从未有过的、以注入平台方式实现的 Windows 覆盖);lsp-local 与 subagent-acp 卸下了自己的进程管道,其子进程如今像 bash 的一样,在插件重载后存活、随组合拆除而终止;一个完整的包(`dsh-subagent-subprocess`)就此消失。seam README 中「只有一个消费方家族」的限制说明也随之退役。 -代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词从一个变为四个),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 +代价是:这道 seam 变宽了(stdio 模式从一种变为三种、终止动词换成 terminate/waitForExit/dispose 这组生命周期表面),未来的后端因此要实现更宽的表面;lsp-local/subagent-acp 的各组合如今都多出 subprocess 这一行组合配置;`SubprocessOutcome` 也不再承载输出,这是仍未发布的堆叠变更内部的一次破坏性形状变更(依照预发布立场,PR2 那一层被就地更新,而非加 shim)。pty-local/mcp-client/SDK/test-support 的 spawn 因所有权归属留在该服务之外,以凭据清除作为共享底线。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a0b43c245a..4bba20e17c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1567,7 +1567,7 @@ Implementations must honor these semantics: - spawn returns immediately with a live handle; `done` resolves at process close with exit facts and rejects only for spawn-level failures. - Collect-mode readers are offset-based and non-consuming, so independent readers never consume one another's output; lossy reads report truncation and the spill file holding the complete stream when one exists. Piped streams are handed to the caller raw and never buffered here. -- SubprocessHandle.kill signals without escalation, SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, and SubprocessHandle.dispose runs the cooperative EOF-first ladder — all tree-scoped on every platform. +- SubprocessHandle.terminate (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL — the only termination verb — and SubprocessHandle.dispose runs the cooperative EOF-first ladder; both tree-scoped on every platform. - Disposal of the service terminates all still-running managed processes and awaits their exit. ```ts cordis-catalog diff --git a/docs/core-data-structures/subprocess.i18n.yaml b/docs/core-data-structures/subprocess.i18n.yaml index 0527cf34a3..55304600c3 100644 --- a/docs/core-data-structures/subprocess.i18n.yaml +++ b/docs/core-data-structures/subprocess.i18n.yaml @@ -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 -subprocess.md: cdd4507c7d37f47ca243ddf38114f5aa6b6f3ad1 -subprocess.zh.md: 78325c3255c42ed591bbd98fdbdb4fdfcba48202 +subprocess.md: b6c316b079177f052302c0b456d300bc54d9ccb6 +subprocess.zh.md: 421ab335b02dfbd20eabd121b96e290eb12d172c diff --git a/docs/core-data-structures/subprocess.md b/docs/core-data-structures/subprocess.md index cdd4507c7d..b6c316b079 100644 --- a/docs/core-data-structures/subprocess.md +++ b/docs/core-data-structures/subprocess.md @@ -132,7 +132,7 @@ interface SubprocessSpawnSpec { ## Handles: streams, readers, and tree-scoped termination -A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `kill(signal)` sends one signal Node-style, `terminate()` escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need. +A spawn returns a live handle immediately. Collect-mode readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; piped streams belong to the caller. Termination is tree-scoped on every platform: `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need. ```ts type-equiv /** @@ -157,17 +157,11 @@ interface SubprocessHandle { readonly collected: SubprocessCollectedOutputs /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ readonly done: Promise - /** - * Send one signal to the process tree, Node-style — no escalation, no - * timers. A no-op after the outcome has settled (the pid may be reused). - * @param signal - the signal to deliver (default `SIGTERM`; Windows - * force-terminates the tree for any value). - */ - kill(signal?: NodeJS.Signals): void /** * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree - * (Windows force-terminates immediately). Idempotent; also triggered by the - * spec's abort signal. + * (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), + * and also triggered by the spec's abort signal. */ terminate(): void /** diff --git a/docs/core-data-structures/subprocess.zh.md b/docs/core-data-structures/subprocess.zh.md index 78325c3255..421ab335b0 100644 --- a/docs/core-data-structures/subprocess.zh.md +++ b/docs/core-data-structures/subprocess.zh.md @@ -132,7 +132,7 @@ interface SubprocessSpawnSpec { ## 句柄:流、读取器与以进程树为范围的终止 -spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`kill(signal)` 以 Node 风格只发送一个信号,`terminate()` 执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树,`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。 +spawn 会立即返回一个实时句柄。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;管道化的流归调用方所有。终止在每个平台上都以进程树为范围:`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级,`waitForExit()` 观察整棵进程树,`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。 ```ts type-equiv /** @@ -157,17 +157,11 @@ interface SubprocessHandle { readonly collected: SubprocessCollectedOutputs /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ readonly done: Promise - /** - * Send one signal to the process tree, Node-style — no escalation, no - * timers. A no-op after the outcome has settled (the pid may be reused). - * @param signal - the signal to deliver (default `SIGTERM`; Windows - * force-terminates the tree for any value). - */ - kill(signal?: NodeJS.Signals): void /** * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree - * (Windows force-terminates immediately). Idempotent; also triggered by the - * spec's abort signal. + * (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), + * and also triggered by the spec's abort signal. */ terminate(): void /** diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index cf879df592..d78f77f796 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2227,7 +2227,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubprocessHandle', - declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n kill(signal?: NodeJS.Signals): void;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n dispose(graces: SubprocessDisposeGraces): Promise;\n}', + declaration: 'export interface SubprocessHandle {\n readonly pid: number;\n readonly stdin: Writable | undefined;\n readonly stdout: Readable | undefined;\n readonly stderr: Readable | undefined;\n readonly collected: SubprocessCollectedOutputs;\n readonly done: Promise;\n terminate(): void;\n waitForExit(signal?: AbortSignal): Promise;\n dispose(graces: SubprocessDisposeGraces): Promise;\n}', }, { name: 'SubprocessOutcome', diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 74f68288e6..62101a403b 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -30,11 +30,11 @@ export interface ConnectionSpec { /** Largest stderr tail retained for diagnostics. */ readonly maxStderrBytes: number /** - * Bound (ms) for draining pipes a surviving helper still holds after the - * server exits; the instance passes its kill grace so exit observation is - * never slower than the escalation it feeds. + * The subprocess spec's `graceMs`: the SIGTERM→SIGKILL window of + * {@link LspConnection.terminate}'s escalation, and the bound for draining + * pipes a surviving helper still holds after the server exits. */ - readonly pipeDrainGraceMs: number + readonly killGraceMs: number /** Static answer to every `workspace/configuration` item. */ readonly configuration: unknown } @@ -98,7 +98,7 @@ export class LspConnection { stdout: 'pipe', stderr: { maxBytes: spec.maxStderrBytes }, }, - graceMs: spec.pipeDrainGraceMs, + graceMs: spec.killGraceMs, // spec.env mixes the scrubbed base with explicit config entries; a // configured DSH_* fact takes the managed channel the seam reserves. ...splitEnvChannels(spec.env), @@ -210,14 +210,9 @@ export class LspConnection { return this.nextId } - /** Request termination of the server's process tree (SIGTERM, no escalation). */ + /** Terminate the server's process tree (the seam's SIGTERM→grace→SIGKILL escalation; idempotent). */ terminate(): void { - this.handle.kill('SIGTERM') - } - - /** Force termination of the server's process tree. */ - kill(): void { - this.handle.kill('SIGKILL') + this.handle.terminate() } /** @@ -235,9 +230,10 @@ export class LspConnection { messages = this.decoder.push(chunk) } catch (error) { // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and - // SIGKILL the whole group so helper processes don't outlive the leader. + // terminate the whole group so helper processes don't outlive the leader (SIGTERM first, then + // the kill grace's SIGKILL — a misbehaving server still gets its bounded flush window). this.fail(asError(error)) - this.handle.kill('SIGKILL') + this.handle.terminate() return } for (const message of messages) this.dispatch(message) diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index b9699706aa..d926ae9428 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -285,8 +285,6 @@ class LocalLspProvider implements LspProvider { initializationOptions: this.config.initializationOptions, maxMessageBytes: this.config.maxMessageBytes, maxStderrBytes: this.config.maxStderrBytes, - // Exit observation must never be slower than the escalation it feeds. - pipeDrainGraceMs: this.config.killGraceMs, shutdownTimeoutMs: this.config.shutdownTimeoutMs, killGraceMs: this.config.killGraceMs, } diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 0718bb6b82..c1f78eaa38 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -35,17 +35,6 @@ export interface InstanceSpec extends ConnectionSpec { readonly initializationOptions: unknown /** Graceful `shutdown`/`exit` budget before escalation (ms). */ readonly shutdownTimeoutMs: number - /** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */ - readonly killGraceMs: number -} - -/** - * Force-kill a process tree only when graceful termination did not make it exit. - * @param treeExited - whether the tree exited within its grace period. - * @param forceKill - forceful process-tree termination primitive. - */ -export function escalateProcessTree(treeExited: boolean, forceKill: () => void): void { - if (!treeExited) forceKill() } /** @@ -311,17 +300,14 @@ export class LspInstance { await abortable(this.connection.closed, signal) } - /** Terminate the tree, escalate after `killGraceMs`, then await leader and helper exit. */ + /** + * Terminate the tree (the seam escalates SIGTERM→`killGraceMs`→SIGKILL), + * then await leader and helper exit. The awaits are unbounded on purpose: + * the seam's escalation already committed to SIGKILL, so quiescence — not + * another timer — is the postcondition disposal owes its callers. + */ private async forceTerminate(): Promise { this.connection.terminate() - const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE') - let treeExited: boolean - try { - treeExited = await this.connection.waitForProcessTreeExit(graceDeadline.signal) - } finally { - graceDeadline[Symbol.dispose]() - } - escalateProcessTree(treeExited, this.connection.kill.bind(this.connection)) await Promise.all([ this.connection.closed, this.connection.waitForProcessTreeExit(), diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index c717e3c886..c07fa77909 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -14,7 +14,7 @@ let open: LspConnection[] = [] afterEach(async () => { for (const conn of open) { - conn.kill() + conn.terminate() await conn.closed } open = [] @@ -33,7 +33,7 @@ function connect( env: { ...scrubbedParentEnv(), ...env }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, - pipeDrainGraceMs: 3_000, + killGraceMs: 3_000, configuration: { setting: 42 }, }, spawnSubprocess, (method, params) => { seen?.push({ method, params }) @@ -66,10 +66,10 @@ describe('LspConnection', () => { await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/) }) - it('treats signaling an already-closed child as a teardown race', async () => { + it('treats terminating an already-closed child as a teardown race', async () => { const conn = connectScript('') await conn.closed - expect(() => { conn.kill() }).not.toThrow() + expect(() => { conn.terminate() }).not.toThrow() }) it('answers a server workspace/configuration request from static config', async () => { @@ -152,7 +152,7 @@ function connectScript(script: string, maxStderrBytes = 100_000, writer?: Connec env: scrubbedParentEnv(), maxMessageBytes: 16_000_000, maxStderrBytes, - pipeDrainGraceMs: 3_000, + killGraceMs: 3_000, configuration: null, }, spawnSubprocess, () => Promise.resolve(null), writer) open.push(conn) @@ -168,7 +168,7 @@ describe('LspConnection edge behavior', () => { env: {}, maxMessageBytes: 1000, maxStderrBytes: 1000, - pipeDrainGraceMs: 3_000, + killGraceMs: 3_000, configuration: null, }, spawnSubprocess, () => Promise.resolve(null)) open.push(conn) diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 7d0ca74d0c..75b9e541fc 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -6,7 +6,6 @@ import { pathToFileURL, fileURLToPath } from 'node:url' import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local' import { encodeMessage } from '@deepseek-ai/dsh-lsp-local' import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts' -import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' @@ -45,7 +44,6 @@ function makeInstance( initializationOptions: { init: true }, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, - pipeDrainGraceMs: 200, shutdownTimeoutMs: 200, killGraceMs: 200, ...overrides, @@ -75,7 +73,6 @@ function scriptInstance(script: string, overrides: Partial = {}): initializationOptions: null, maxMessageBytes: 16_000_000, maxStderrBytes: 100_000, - pipeDrainGraceMs: 150, shutdownTimeoutMs: 150, killGraceMs: 150, ...overrides, @@ -258,14 +255,6 @@ describe('LspInstance query and abort', () => { }) describe('LspInstance disposal', () => { - it('escalates only when the process tree survives its grace period', () => { - const forceKill = vi.fn() - escalateProcessTree(false, forceKill) - expect(forceKill).toHaveBeenCalledOnce() - escalateProcessTree(true, forceKill) - expect(forceKill).toHaveBeenCalledOnce() - }) - it('lets a server finish protocol exit before signal escalation', async () => { const marker = join(root, 'graceful-exit.log') const instance = makeInstance({ diff --git a/packages/subprocess/README.i18n.yaml b/packages/subprocess/README.i18n.yaml index 5224774c27..f536302eea 100644 --- a/packages/subprocess/README.i18n.yaml +++ b/packages/subprocess/README.i18n.yaml @@ -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 -README.md: 657855aff67230ee22b8137ae3aabc76aff8f860 -README.zh.md: 5281a0d6eddb38974d1225220bab08880224f14b +README.md: 64e4740c7ac2706e45bb3517891504bf31a6109b +README.zh.md: e30b6c7f51ed0dffba15a6d1dff632e4ff4c6402 diff --git a/packages/subprocess/README.md b/packages/subprocess/README.md index 657855aff6..64e4740c7a 100644 --- a/packages/subprocess/README.md +++ b/packages/subprocess/README.md @@ -6,7 +6,7 @@ The shared home for spawning managed child-process trees: fully-specified spawn | Package | ctx key | Role | |---|---|---| -| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, kill/terminate/waitForExit/dispose), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary | +| [`subprocess`](subprocess/README.md) (`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | The seam: abstract `SubprocessService.spawn(spec)`, the fully-explicit `SubprocessSpawnSpec` with per-stream stdio dispositions, `SubprocessHandle` (streams, offset-based readers, terminate/waitForExit/dispose), and the shared scrub + `DSH_*`/`CollectedOutput` vocabulary | | [`subprocess-local`](subprocess-local/README.md) (`@deepseek-ai/dsh-subprocess-local`) | — | The local implementation: detached process trees, per-disposition stream wiring, tail-keep truncation with bounded private spill files, the `DSH_*` merge order, tree signalling with escalation, the dispose ladder, and terminate-and-join disposal | The service owns process lifetime across consumer reloads; consumers own what a process means (a bash command, a future non-shell runner) and every default that shapes one. diff --git a/packages/subprocess/README.zh.md b/packages/subprocess/README.zh.md index 5281a0d6ed..e30b6c7f51 100644 --- a/packages/subprocess/README.zh.md +++ b/packages/subprocess/README.zh.md @@ -6,7 +6,7 @@ spawn 受管子进程树的共用归属位置:完全显式的 spawn spec,其 | 包(package) | ctx 键 | 角色 | |---|---|---| -| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式的 `SubprocessSpawnSpec`、`SubprocessHandle`(流、基于偏移量的读取器、kill/terminate/waitForExit/dispose),以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 | +| [`subprocess`](subprocess/README.md)(`@deepseek-ai/dsh-subprocess`) | `ctx.subprocess` | seam 本体:抽象的 `SubprocessService.spawn(spec)`、完全显式且带按流划分 stdio 处置方式的 `SubprocessSpawnSpec`、`SubprocessHandle`(流、基于偏移量的读取器、terminate/waitForExit/dispose),以及共享的凭据清除 + `DSH_*`/`CollectedOutput` 词汇 | | [`subprocess-local`](subprocess-local/README.md)(`@deepseek-ai/dsh-subprocess-local`) | 无 | 本地实现:detached 进程树、按处置方式接线的流、附带有界私有 spill 文件的尾部保留截断、`DSH_*` 合并次序、带升级的进程树信号发送、dispose 阶梯,以及先终止再等待退出的 dispose | 服务拥有跨消费方重载的进程存续期;消费方拥有一个进程的含义(一条 bash 命令、未来的非 shell 运行器)以及塑造它的每一项默认值。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 2e8c1fdae4..d7ad897392 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -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 -README.md: 08cc2ce7d92569222b99992d0f4c43551a2c9623 -README.zh.md: da230ba37d406a6ad4ec669ceead45f2c2dd7069 +README.md: b16d5e9a7eabb39db549b9fd6452e8fecee73022 +README.zh.md: 81b3f9ec8341a73732e2cf342bb0f9fe5c290357 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 08cc2ce7d9..b16d5e9a7e 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -6,7 +6,7 @@ Local implementation of the [`@deepseek-ai/dsh-subprocess`](../subprocess/README ## Behavior (and where it came from) -- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent); `kill(signal)` sends exactly one signal and is a no-op after settlement; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. +- **Detached process trees with platform-correct signalling** — POSIX children are spawned `detached` (own process group) and signalled by negative pgid with a direct-child fallback; Windows terminates the tree via `taskkill /PID /T /F` (injectable for tests). `terminate()` — the handle's only termination verb — sends SIGTERM then SIGKILL after the spec's grace (OpenCode's escalation; pipelines and subshells die with the parent) and is a no-op once the tree is gone; `dispose(graces)` runs stdin-EOF → SIGTERM → SIGKILL with caller-supplied windows and one memoized disposal per handle. After the leader exits, still-open pipes receive the same bounded drain grace so a surviving descendant cannot hold the outcome open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — the same caveat as the surveyed tools. - **Per-stream dispositions** — `'pipe'` hands the raw stream to the caller untouched (protocol framing stays consumer-owned); `'inherit'` passes the parent descriptor through; collect mode keeps the in-memory TAIL beyond its cap (errors and results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a private temp file when a spill cap is configured — omitting `spill` keeps only the tail, the diagnostic shape. A stream larger than the spill cap discards its now-incomplete spill and returns only the marked truncated tail; spill fds are sealed at settlement, and a failed final close withholds the path rather than advertising an incomplete file. Spill files are `0600` with random names under a lazily-created `0700` per-process directory. - **Credential scrub + managed `DSH_*` merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; a spec's ordinary `env` merges after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index da230ba37d..81b3f9ec83 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -6,7 +6,7 @@ ## 行为(以及设计来源) -- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()` 先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束);`kill(signal)` 恰好发送一个信号,结算后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 +- **带平台正确信号发送的 detached 进程树**:POSIX 子进程使用 `detached` spawn(拥有独立进程组),信号以负 pgid 发送并以直接子进程作为回退;Windows 通过 `taskkill /PID /T /F` 终止进程树(可为测试注入)。`terminate()`(句柄唯一的终止动词)先发送 SIGTERM,经过 spec 的宽限期后再发送 SIGKILL(沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束),进程树消亡后为空操作;`dispose(graces)` 以调用方提供的时间窗运行 stdin EOF→SIGTERM→SIGKILL 阶梯,dispose(资源释放)按句柄 memoize 化、只执行一次。组长进程退出后,仍然打开的管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地拖住结果不结算。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 - **按流划分的处置方式**:`'pipe'` 把原始流原样交给调用方(协议分帧仍归消费方所有);`'inherit'` 直通父进程的描述符;收集模式(collect)在输出超过上限后于内存中保留尾部(错误与结果通常聚集在末尾,沿用 pi/OpenCode 的理由),并在配置了 spill 上限时把完整流追加到一个私有临时文件;省略 `spill` 则只保留尾部,即诊断尾部的形状。某条流大于 spill 上限时,会丢弃已不完整的 spill,仅返回带截断标记的尾部;spill 文件描述符在结算时封存,最终关闭失败时则不公布路径,以免声称存在不完整的文件。spill 文件权限为 `0600`、名称随机,位于按需延迟创建的 `0700` 每进程目录之下。 - **凭据清除 + 受管 `DSH_*` 合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的普通 `env` 在清除后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止陈旧的嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器以全流字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 8716f50b0c..1e069bbb90 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -392,11 +392,12 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } } - const kill = (sig: NodeJS.Signals = 'SIGTERM'): void => { - // Guard on TREE liveness, not outcome settlement: a TERM-trapping helper - // can outlive the settled direct child and must stay signalable, while a - // fully-dead tree (possible pid reuse) must not be re-signalled from a - // caller's finally block. + // The dispose ladder's tier primitive (not on the handle — terminate() is + // the only consumer-facing termination verb). Guards on TREE liveness, not + // outcome settlement: a TERM-trapping helper can outlive the settled direct + // child and must stay signalable, while a fully-dead tree (possible pid + // reuse) must not be re-signalled by a later tier. + const kill = (sig: NodeJS.Signals): void => { if (!treeAlive()) return signalTree(platform, pid, sig, child, taskkill) } @@ -404,15 +405,13 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const terminate = (): void => { if (graceTimer !== undefined) return // escalation already in flight if (!treeAlive()) return - signalTree(platform, pid, 'SIGTERM', child, taskkill) + kill('SIGTERM') // The escalation must survive direct-child settlement — the leader dying // does not mean the tree died — so settle does not clear this timer, and - // it re-probes tree liveness before force-killing. It stays ref'd: the - // pending SIGKILL is a commitment, and a parent exiting before it fires - // would orphan a trapped survivor. Self-bounds at graceMs. - graceTimer = setTimeout(() => { - if (treeAlive()) signalTree(platform, pid, 'SIGKILL', child, taskkill) - }, spec.graceMs) + // kill() re-probes tree liveness before force-killing. It stays ref'd: + // the pending SIGKILL is a commitment, and a parent exiting before it + // fires would orphan a trapped survivor. Self-bounds at graceMs. + graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) } // The caller owns timeout classification; this layer only reacts to abort. @@ -514,7 +513,6 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter ...stderrCollector !== undefined ? { stderr: stderrCollector } : {}, }, done, - kill, terminate, waitForExit, dispose, diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 4b3e16fd5a..96d9fed7ac 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -165,26 +165,15 @@ describe('spawnSubprocess', () => { expect(result.signal).toBe('SIGKILL') }) - it('kill() sends one signal Node-style, without escalation', async () => { - const running = spawnSubprocess(spec('trap \'\' TERM; echo armed; sleep 60', { graceMs: 100 })) - await waitForStdout(running, 'armed\n') - running.kill() // trapped SIGTERM, no SIGKILL follow-up - await new Promise(resolve => setTimeout(resolve, 400)) - expect(running.collected.stdout).toBeDefined() - running.kill('SIGKILL') // explicit signal choice, still no timers - const result = await running.done - expect(result.signal).toBe('SIGKILL') - }) - - it('kills the whole process group (grandchildren die too)', async () => { - // The subshell writes the sleep's pid then waits on it; killing the + it('terminates the whole process group (grandchildren die too)', async () => { + // The subshell writes the sleep's pid then waits on it; terminating the // group must take the sleep down with bash. const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`) const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) const grandchild = await waitForPidFile(pidFile) expect(grandchild).toBeGreaterThan(0) - running.kill() + running.terminate() const result = await running.done expect(result.signal).toBe('SIGTERM') await waitGone(grandchild) @@ -452,22 +441,6 @@ describe('killGroup', () => { expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() }) - it('handle.kill() after the tree died delivers no termination signal', async () => { - // Cleanup code commonly kills handles in a finally; once the tree is gone - // the pid may be reused, so a late kill must deliver nothing (the - // liveness PROBE — signal 0 — is the only process.kill allowed). - const running = spawnSubprocess(spec('true')) - await running.done - await running.waitForExit() - const spy = vi.spyOn(process, 'kill') - try { - running.kill() - const delivered = spy.mock.calls.filter(([, sig]) => sig !== 0) - expect(delivered).toEqual([]) - } finally { - spy.mockRestore() - } - }) }) describe('stdio dispositions', () => { @@ -541,7 +514,7 @@ describe('dispose ladder', () => { }) describe('windows tree semantics (injected platform)', () => { - it('kill and terminate route through taskkill by root pid', async () => { + it('terminate routes through taskkill by root pid', async () => { const killed: number[] = [] const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), { spillDir, @@ -672,6 +645,18 @@ describe('coverage seams', () => { expect(running.collected.stderr!.readFrom(0).text).toBe('err\n') }) + it("an 'inherit' stderr with collected stdout wires only the requested collector", async () => { + const running = spawnSubprocess({ + ...spec('echo out; echo to-parent >&2'), + stdio: { stdin: 'ignore', stdout: { maxBytes: 1000 }, stderr: 'inherit' }, + }) + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(running.stderr).toBeUndefined() + expect(running.collected.stderr).toBeUndefined() + expect(running.collected.stdout!.readFrom(0).text).toBe('out\n') + }) + it('terminate() after the tree died delivers no termination signal', async () => { const running = spawnSubprocess(spec('true')) await running.done diff --git a/packages/subprocess/subprocess/README.i18n.yaml b/packages/subprocess/subprocess/README.i18n.yaml index 31f0fc4e84..26a157c442 100644 --- a/packages/subprocess/subprocess/README.i18n.yaml +++ b/packages/subprocess/subprocess/README.i18n.yaml @@ -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 -README.md: 2cb7a5ebce404c440e625dea844ed28ceadb06f3 -README.zh.md: a3211834e065359e813e8148a8f6a6a15f8f89b6 +README.md: d760be118e5eaf41d038a5854a8e129cdc41c349 +README.zh.md: a174724e51ce537b3b7121bf6e1fa442d182fe1d diff --git a/packages/subprocess/subprocess/README.md b/packages/subprocess/subprocess/README.md index 2cb7a5ebce..d760be118e 100644 --- a/packages/subprocess/subprocess/README.md +++ b/packages/subprocess/subprocess/README.md @@ -9,7 +9,7 @@ The subprocess seam (`ctx.subprocess`). The abstract `SubprocessService` exposes - `spawn(spec)` returns immediately with a live handle; `done` resolves at process close with exit facts (`SubprocessOutcome` carries no output and no cause classification) and rejects only for spawn-level failures. - The spec is fully explicit — argv, cwd, per-stream stdio dispositions, grace — because deployment-varying defaults belong to the calling seam's config, not to a hidden subprocess-service default (the `dsh-bash` request/spec split is the owning template). `argv` is never shell-interpreted; a consumer that wants a shell passes `['bash', '-c', command]` itself. - Stdio is Node-shaped per stream: `'pipe'` hands the caller the raw stream for its own protocol framing (LSP JSON-RPC, ACP ndjson), `'inherit'` passes the parent descriptor through for diagnostics, and collect mode (`{ maxBytes, spill? }`) buffers a bounded tail with an optional full-stream spill file. Collect readers take whole-stream byte offsets and never consume, so independent readers cannot steal one another's deltas; a read whose offset slid out of the in-memory tail is `lossy` and points at the spill file when one exists. Collected output stays readable after settlement. -- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `kill(signal)` sends one signal Node-style and is a no-op after settlement, `terminate()` (and the spec's abort signal) escalates SIGTERM→grace→SIGKILL, `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification). +- Termination is tree-scoped on every platform (POSIX detached groups with direct-child fallback; Windows `taskkill /T`): `terminate()` — the only termination verb — escalates SIGTERM→grace→SIGKILL (idempotent, driven by the spec's abort signal too, a no-op once the tree is gone), `waitForExit()` observes the whole tree, and `dispose(graces)` runs the cooperative stdin-EOF→SIGTERM→SIGKILL ladder out-of-process children need — the manager reacts but never classifies why (callers own deadlines and cause classification). - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` are the one shared scrub definition: ambient credential-shaped and `DSH_*` names are dropped, explicit `env` merges after the scrub (a deliberately forwarded key survives), and `dshEnv` carries current harness facts on its own validated channel; `splitEnvChannels()` partitions a consumer config's single mixed env map onto those two channels (lsp-local servers and the ACP backend expose one map, and a configured `DSH_*` fact must ride the managed channel the ordinary one rejects). Spawners that cannot route through the service (node-pty backends, SDK-managed transports) import the scrub. - Disposal of the service terminates all still-running managed processes and awaits their exit. diff --git a/packages/subprocess/subprocess/README.zh.md b/packages/subprocess/subprocess/README.zh.md index a3211834e0..a174724e51 100644 --- a/packages/subprocess/subprocess/README.zh.md +++ b/packages/subprocess/subprocess/README.zh.md @@ -9,7 +9,7 @@ - `spawn(spec)` 立即返回一个实时句柄;`done` 在进程关闭时以退出事实 resolve(`SubprocessOutcome` 不携带输出,也不携带原因分类),仅在 spawn 层面失败时 reject。 - spec 完全显式(argv、cwd、按流划分的 stdio 处置方式(disposition)、宽限期),因为随部署变化的默认值属于调用方 seam 的配置,而不属于某个隐藏的进程管理器默认值(`dsh-bash` 的 request/spec 拆分是这条规则的所属模板)。`argv` 绝不经过 shell 解释;需要 shell 的消费方自行传入 `['bash', '-c', command]`。 - stdio 按流采用 Node 形状:`'pipe'` 把原始流交给调用方做自己的协议分帧(LSP 的 JSON-RPC、ACP(Agent Client Protocol)的 ndjson),`'inherit'` 直通父进程描述符以承载诊断输出,收集模式(collect)`{ maxBytes, spill? }` 则缓冲一段有界尾部,外加可选的完整流 spill 文件。收集模式的读取器接受全流字节偏移量且从不消费,因此独立的读取器不会抢走彼此的增量;偏移量滑出内存尾部窗口的读取标记为 `lossy`,并在 spill 文件存在时指向它。收集到的输出在结算后仍可读取。 -- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`kill(signal)` 以 Node 风格只发送一个信号,结算后为空操作;`terminate()`(以及 spec 的 abort 信号)执行 SIGTERM→宽限期→SIGKILL 升级;`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。 +- 终止在每个平台上都以进程树为范围(POSIX 用 detached 进程组并以直接子进程回退;Windows 用 `taskkill /T`):`terminate()`(唯一的终止动词)执行 SIGTERM→宽限期→SIGKILL 升级(幂等,也由 spec 的 abort 信号驱动,进程树消亡后为空操作);`waitForExit()` 观察整棵进程树;`dispose(graces)` 运行进程外子进程所需的协作式 stdin EOF→SIGTERM→SIGKILL 阶梯。管理器只响应中止,但绝不判定原因(deadline 与原因分类归调用方所有)。 - `scrubbedParentEnv()` / `SENSITIVE_ENV_PATTERN` 是唯一一份共享的凭据清除定义:环境中形似凭据的名称与 `DSH_*` 名称都会被丢弃,显式 `env` 在清除之后合并(有意转发的键会保留下来),`dshEnv` 则经由自身带校验的通道携带当前 harness 事实;`splitEnvChannels()` 把消费方配置中单一的混合 env 映射按这两条通道切分(lsp-local 的服务器配置与 ACP 后端只暴露一个映射,而配置的 `DSH_*` 事实必须走受管通道,普通通道会拒绝它)。无法把 spawn 路由到该服务的调用点(node-pty 后端、由 SDK 管理的传输层)改为导入凭据清除函数。 - 服务自身的 dispose(资源释放)会终止所有仍在运行的受管进程并等待其退出。 diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index e4cfaae901..d69b08c05f 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -102,10 +102,10 @@ declare module 'cordis' { * readers never consume one another's output; lossy reads report truncation * and the spill file holding the complete stream when one exists. Piped * streams are handed to the caller raw and never buffered here. - * - {@link SubprocessHandle.kill} signals without escalation, - * {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates - * SIGTERM→grace→SIGKILL, and {@link SubprocessHandle.dispose} runs the - * cooperative EOF-first ladder — all tree-scoped on every platform. + * - {@link SubprocessHandle.terminate} (and the spec's abort signal) escalates + * SIGTERM→grace→SIGKILL — the only termination verb — and + * {@link SubprocessHandle.dispose} runs the cooperative EOF-first ladder; + * both tree-scoped on every platform. * - Disposal of the service terminates all still-running managed processes * and awaits their exit. */ diff --git a/packages/subprocess/subprocess/src/types.ts b/packages/subprocess/subprocess/src/types.ts index 4c667919c7..1c831c2805 100644 --- a/packages/subprocess/subprocess/src/types.ts +++ b/packages/subprocess/subprocess/src/types.ts @@ -206,17 +206,11 @@ export interface SubprocessHandle { readonly collected: SubprocessCollectedOutputs /** Resolves at process close with exit facts; rejects only for spawn-level failures. */ readonly done: Promise - /** - * Send one signal to the process tree, Node-style — no escalation, no - * timers. A no-op after the outcome has settled (the pid may be reused). - * @param signal - the signal to deliver (default `SIGTERM`; Windows - * force-terminates the tree for any value). - */ - kill(signal?: NodeJS.Signals): void /** * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree - * (Windows force-terminates immediately). Idempotent; also triggered by the - * spec's abort signal. + * (Windows force-terminates immediately) — the seam's only termination + * verb. Idempotent, a no-op once the tree is gone (the pid may be reused), + * and also triggered by the spec's abort signal. */ terminate(): void /** diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index 9fb68f403f..ec8718dfa9 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -21,7 +21,6 @@ class StubSubprocessService extends SubprocessService { stderr: undefined, collected, done: Promise.resolve({ exitCode: 0, signal: null }), - kill: () => {}, terminate: () => {}, waitForExit: () => Promise.resolve(true), dispose: (_graces: SubprocessDisposeGraces) => Promise.resolve(), @@ -41,7 +40,6 @@ describe('SubprocessService seam', () => { }) expect(handle.pid).toBe(1) expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: '', nextOffset: 0, lossy: false }) - handle.kill() handle.terminate() await expect(handle.waitForExit()).resolves.toBe(true) await expect(handle.dispose({ eofGraceMs: 1, graceMs: 1 })).resolves.toBeUndefined() From 9d2667f900bde40b125a3e735223e22af57e6336 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 06:43:33 +0800 Subject: [PATCH 43/70] docs: restore the rejected landstrip note's lifecycle folder The origin/master merge's directory-rename detection relocated the trio to implemented/feature/ (master's note-archiving sweep renamed many implemented/ files, and the rejection move predated the merge); move it back to rejected/feature/ where the rejection commit put it. --- ...26-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml | 0 .../2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md | 0 .../2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename .agents/notes/{implemented => rejected}/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml (100%) rename .agents/notes/{implemented => rejected}/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md (100%) rename .agents/notes/{implemented => rejected}/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md (100%) diff --git a/.agents/notes/implemented/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml similarity index 100% rename from .agents/notes/implemented/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.i18n.yaml diff --git a/.agents/notes/implemented/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md similarity index 100% rename from .agents/notes/implemented/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.md diff --git a/.agents/notes/implemented/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md b/.agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md similarity index 100% rename from .agents/notes/implemented/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md rename to .agents/notes/rejected/feature/2026-07-26-evaluate-landstrip-for-windows-sandbox-rung.zh.md From 7d6450f57c1ebe7b79e747ab5ab986fed82ee578 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:57:30 +0800 Subject: [PATCH 44/70] docs: regenerate the module graph after the master merge The merge composed master's new package edges with this branch's dsh-timeout dependency removal; the checked-in graph had neither. --- docs/module-graph.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 07569ee5f5..0c90a30dba 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -295,7 +295,6 @@ flowchart TD pkg_storage_sqlite --> pkg_storage pkg_subprocess_local --> pkg_invariants pkg_subprocess_local --> pkg_subprocess - pkg_subprocess_local --> pkg_timeout pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_llm pkg_llm_deepseek --> pkg_timeout @@ -901,7 +900,7 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | -| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | From a70923ba216eb09c91634e6f0db1059ae089baa1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:51:47 +0800 Subject: [PATCH 45/70] fix(session-checkpoint-policy): fail fast on an impossible crash marker vi.waitFor retries every callback throw, so the mismatch branch inside the callback waited the full 30s deadline for a fixture that writes the marker once and cannot recover. Terminal states (complete marker, or content that can no longer become the expected marker) now resolve out of the retry loop and the mismatch throws after it, restoring the old loop's immediate failure. --- .../tests/crash-recovery.e2e.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts index d87ba13b3a..25c7d5797d 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts @@ -19,17 +19,21 @@ const roots: string[] = [] const CHILD_FAILPOINT_TIMEOUT_MS = 30_000 async function waitForMarker(path: string, expected: string): Promise { - return await vi.waitFor(async () => { - const content = await readFile(path, 'utf8').catch((error: unknown) => { + // vi.waitFor retries every callback throw, so terminal states RESOLVE out + // of the retry loop (complete marker, or content that can no longer become + // the expected marker) and only the still-in-progress states throw-to-retry. + const content = await vi.waitFor(async () => { + const current = await readFile(path, 'utf8').catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error throw new Error(`crash child did not publish failpoint ${JSON.stringify(expected)} at ${path}`, { cause: error }) }) - if (content === expected) return content - if (!expected.startsWith(content)) { - throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`) - } + if (current === expected || !expected.startsWith(current)) return current throw new Error(`crash child has not finished publishing failpoint ${JSON.stringify(expected)}`) }, { interval: 10, timeout: CHILD_FAILPOINT_TIMEOUT_MS }) + if (content !== expected) { + throw new Error(`crash child wrote unexpected failpoint ${JSON.stringify(content)}`) + } + return content } async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> { From 073c698c72940adaa6a04427232117c32edb8827 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:15:09 +0800 Subject: [PATCH 46/70] =?UTF-8?q?ci:=20finish=20the=20corepack=20sweep=20?= =?UTF-8?q?=E2=80=94=20pnpm/action-setup=20everywhere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the symmetric-cache conversion: provisioning and caching are separable concerns, so the remaining 8 corepack enable sites (the three enterprise restore-only PR jobs, the required Windows job, serial-macos/serial-windows, the cache-less ci.yml jobs, and sandbox.yml) now provision pnpm via pnpm/action-setup@v4 too — only the provisioning line moves; every deliberate cache shape (enterprise restore-only pairing, serial-linux producer, Windows cache-less) is byte-preserved. corepack is gone from CI entirely. Agent Note retitled to the decision's real shape (provisioning policy, with caching as per-job policy on top), EN+ZH, pair re-recorded; composite-action and stop-at-cache-workflows alternatives recorded. --- ...n-setup-for-symmetric-ci-caching.i18n.yaml | 4 +- ...m-action-setup-for-symmetric-ci-caching.md | 28 ++++++------- ...ction-setup-for-symmetric-ci-caching.zh.md | 28 ++++++------- .github/workflows/ci.yml | 41 +++++++++---------- .github/workflows/sandbox.yml | 5 +-- 5 files changed, 52 insertions(+), 54 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml index 23f0580c07..0b452fcf81 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml @@ -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 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 0cc0a09dfe1019d80b08599a245ce4bb044d81c5 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 477f8eed61579eb9c4f18701a39307daf098407d +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: d734f6be65affb15f7e56c08b0b6d268002a44d8 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 77ea0aa1cc7443ca7cd9e89b652a9c908bab64e5 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md index 0cc0a09dfe..d734f6be65 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md @@ -1,4 +1,4 @@ -# Agent Note: Use pnpm/action-setup for symmetric CI pnpm caching +# Agent Note: Provision CI pnpm via pnpm/action-setup Status: implemented @@ -6,27 +6,27 @@ English | [中文](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md) ## Problem -Five workflows repeated a hand-rolled three-step pnpm setup — `corepack enable`, `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml` (~40–60 YAML lines total). The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — was already proven in-repo in `landlock-run.yml`, and also insulates against corepack's removal from newer Node distributions. +Every workflow hand-provisioned pnpm with `corepack enable`, and five of them further repeated a hand-rolled cache setup — `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml` (~40–60 YAML lines of drifting copies). The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — was already proven in-repo in `landlock-run.yml`, and corepack's removal from newer Node distributions made every `corepack enable` a known future break. ## Decision -The symmetric-cache setups use `pnpm/action-setup@v4` followed by `actions/setup-node` with `cache: pnpm`, the `landlock-run.yml` pattern: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input, mirroring the required Windows job's deliberate skip; the consolidated benchmark caches on both platforms as before. +`pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. Caching remains per-job policy on top of it, in three deliberate shapes: -Explicitly NOT converted: - -- the three enterprise-runner PR jobs in `ci.yml` — they deliberately use `actions/cache/restore` only, keeping cache compression/upload off the paid latency-critical path, an asymmetry `setup-node`'s cache cannot express; -- the Windows job, which deliberately skips the store cache; -- the store-cache step of `ci.yml`'s serial-linux job — the job swaps `corepack enable` for `pnpm/action-setup@v4`, but its `pnpm store path` + `actions/cache@v4` steps stay hand-rolled because the master-push serial-linux run is the save side that populates the exact key and path the enterprise restore-only jobs consume; converting the producer to `setup-node`'s own key format would silently starve their restores. +- **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms. +- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps, unchanged): the three enterprise-runner PR jobs restore without saving, keeping cache compression/upload off the paid latency-critical path — an asymmetry `setup-node`'s cache cannot express — and the master-push serial-linux job keeps its `pnpm store path` + `actions/cache@v4` save side, because it populates the exact key and path those restore-only jobs consume; converting the producer to `setup-node`'s key format would silently starve their restores. +- **Cache-less** (no store cache at all): the required Windows job and serial-windows (many-file store extraction is slower than a clean install there), serial-macos, sandbox.yml, and the coverage/consumers enterprise jobs that already restore via the shared enterprise key. ## Alternatives considered -- **Keep the hand-rolled steps.** They work, but they are five drifting copies of setup boilerplate, and the corepack dependency is a known future break. -- **Convert everything including the enterprise jobs.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority. -- **Convert serial-linux's store cache too.** Rejected during implementation: the proposal counted serial-linux among the symmetric setups, but its cache step is the producer half of the enterprise jobs' restore-only pairing — moving it to `setup-node`'s key format is the enterprise conversion by another route. +- **Keep the hand-rolled steps.** They worked, but they were drifting copies of setup boilerplate, and the corepack dependency was a known future break. +- **Convert the enterprise jobs' caching to `cache: pnpm`.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority. +- **Convert serial-linux's store cache.** Rejected during implementation: the original proposal counted serial-linux among the symmetric setups, but its cache step is the producer half of the enterprise jobs' restore-only pairing — moving it to `setup-node`'s key format is the enterprise conversion by another route. +- **Stop at the cache-bearing workflows and leave the other `corepack enable` sites.** Rejected on review follow-up: provisioning and caching are separable concerns, and leaving corepack in the cache-less jobs kept the future break and two provisioning idioms for no benefit. +- **A composite action wrapping action-setup + setup-node.** Rejected for now: the remaining per-job variation (node-version matrices, per-platform conditional caching, the restore-only pairing) is deliberate policy, not boilerplate — a wrapper would grow mirroring inputs or flatten a real asymmetry, and the two-line pair is already near the floor. ## Consequences -- The corepack dependency is gone from every converted workflow; pnpm arrives via the pnpm team's official action, already trusted in-repo (`landlock-run.yml`). -- The cache-key format changed once; one cold run per converted lane repopulates it, after which hit rates match the old steps. The built-in key spans platform, arch, and the lockfile hash but not the Node version, so the node-compat matrix legs share one store entry — safe, because the pnpm store is Node-version-independent. +- The corepack dependency is gone from CI entirely; pnpm arrives via the pnpm team's official action everywhere, and the version pin stays single-sourced in `package.json`'s `packageManager` field. +- The cache-key format changed once for converted lanes; one cold run repopulated it, after which hit rates match the old steps. The built-in key spans platform, arch, and the lockfile hash but not the Node version, so the node-compat matrix legs share one store entry — safe, because the pnpm store is Node-version-independent. - `setup-node`'s built-in pnpm cache restores by exact key only, with no `restore-keys` prefix fallback: a `pnpm-lock.yaml` change starts a converted lane from a cold store instead of seeding from the previous entry. -- About 75 net lines of workflow YAML removed; the enterprise-runner PR jobs and the Windows job are byte-identical, and serial-linux keeps producing the key they restore. +- About 75 net lines of workflow YAML removed. The enterprise-runner PR jobs' and Windows jobs' cache behavior is unchanged (only their provisioning line moved to the action), and serial-linux keeps producing the key the restore-only jobs consume. diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md index 477f8eed61..77ea0aa1cc 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 用 pnpm/action-setup 实现对称的 CI pnpm 缓存 +# Agent Note: 经由 pnpm/action-setup 提供 CI 的 pnpm Status: implemented @@ -6,27 +6,27 @@ Status: implemented ## 问题 -五个工作流曾重复着同一套手写(hand-rolled)的三步 pnpm 设置——`corepack enable`、`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业(合计约 40–60 行 YAML)。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——当时已在仓库内的 `landlock-run.yml` 中得到验证,同时还能隔绝 corepack 被从较新 Node 发行版中移除的影响。 +每个工作流都曾用 `corepack enable` 手工提供 pnpm,其中五个还各自重复着一套手写(hand-rolled)的缓存设置——`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业(约 40–60 行各自漂移的 YAML 副本)。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——当时已在仓库内的 `landlock-run.yml` 中得到验证,而 corepack 被从较新 Node 发行版中移除,使每一处 `corepack enable` 都成了已知的未来失效点。 ## 决策 -各对称缓存设置现采用 `pnpm/action-setup@v4` 后接带 `cache: pnpm` 的 `actions/setup-node`,即 `landlock-run.yml` 的模式:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux,与必需 Windows 作业刻意跳过缓存的做法保持一致;consolidated benchmark 与之前一样在两个平台上都启用缓存。 +`pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。缓存仍是叠加其上的按作业政策,保持三种刻意的形态: -以下明确未做转换: - -- `ci.yml` 中运行在企业 runner 上的三个 PR(Pull Request)作业——它们刻意只用 `actions/cache/restore`,把缓存压缩/上传挡在付费且延迟敏感的关键路径之外,这种不对称是 `setup-node` 的缓存无法表达的; -- Windows 作业,它刻意跳过 store 缓存; -- `ci.yml` 中 serial-linux 作业的 store 缓存步骤——该作业把 `corepack enable` 换成了 `pnpm/action-setup@v4`,但其 `pnpm store path` + `actions/cache@v4` 步骤仍保持手写,因为 master 推送触发的 serial-linux 运行正是写入侧,负责填充企业只恢复不上传(restore-only)作业所消费的那个精确缓存键与路径;把生产者改成 `setup-node` 自有的键格式,会悄然断供它们的恢复。 +- **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。 +- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤,保持不变):企业 runner 上的三个 PR(Pull Request)作业只恢复不保存,把缓存压缩/上传挡在付费且延迟敏感的关键路径之外——这种不对称是 `setup-node` 的缓存无法表达的;master 推送触发的 serial-linux 作业保留其 `pnpm store path` + `actions/cache@v4` 写入侧,因为它负责填充那些只恢复不上传(restore-only)作业所消费的精确缓存键与路径;把生产者改成 `setup-node` 的键格式,会悄然断供它们的恢复。 +- **无缓存**(完全不设 store 缓存):必需的 Windows 作业与 serial-windows(在那里解压海量小文件的 store 缓存比干净安装更慢)、serial-macos、sandbox.yml,以及本就经共享企业键恢复的 coverage/consumers 企业作业。 ## 曾考虑的替代方案 -- **保留手写步骤。** 它们能用,但那是五份会各自漂移的设置样板副本,而且对 corepack 的依赖是已知的未来失效点。 -- **连企业作业在内全部转换。** 否决:只恢复不上传的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。 -- **连 serial-linux 的 store 缓存也转换。** 实现期间否决:提案曾把 serial-linux 计入对称设置,但其缓存步骤是企业作业只恢复不上传配对中的生产者一半——把它改成 `setup-node` 的键格式,等于换条路径做了企业作业的转换。 +- **保留手写步骤。** 它们能用,但那是会各自漂移的设置样板副本,而且对 corepack 的依赖是已知的未来失效点。 +- **把企业作业的缓存也转换成 `cache: pnpm`。** 否决:只恢复不上传的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。 +- **转换 serial-linux 的 store 缓存。** 实现期间否决:原提案曾把 serial-linux 计入对称设置,但其缓存步骤是企业作业只恢复不上传配对中的生产者一半——把它改成 `setup-node` 的键格式,等于换条路径做了企业作业的转换。 +- **只转换带缓存的工作流,留下其余 `corepack enable` 站点。** 评审跟进时否决:提供 pnpm 与缓存是可分离的关注点,在无缓存作业里留下 corepack 只会保留未来失效点和两套并存的提供方式,毫无收益。 +- **用一个组合 action 包装 action-setup + setup-node。** 暂不采纳:剩余的按作业差异(node 版本矩阵、按平台的条件缓存、只恢复不上传配对)是刻意的政策而非样板——包装层要么长出镜像这些差异的输入,要么抹平一处真实的不对称,而两行的组合已接近下限。 ## 后果 -- 每个已转换的工作流都不再依赖 corepack;pnpm 经由 pnpm 团队的官方 action 提供,该 action 已在仓库内获得信任(`landlock-run.yml`)。 -- 缓存键格式变更了一次;每条已转换泳道各跑一次冷运行以重建缓存,此后命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 矩阵的各条腿共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。 +- corepack 依赖已从 CI 中彻底消失;pnpm 在所有工作流中都经由 pnpm 团队的官方 action 提供,版本锁定继续单一来源于 `package.json` 的 `packageManager` 字段。 +- 已转换泳道的缓存键格式变更了一次;各跑一次冷运行重建缓存后,命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 矩阵的各条腿共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。 - `setup-node` 内建的 pnpm 缓存只按精确键恢复,没有 `restore-keys` 前缀回退:`pnpm-lock.yaml` 一旦变更,已转换泳道会从冷 store 起步,而不是从上一条缓存记录播种。 -- 净删除约 75 行工作流 YAML;企业 runner 上的 PR 作业与 Windows 作业逐字节未动,serial-linux 继续生产它们所恢复的缓存键。 +- 净删除约 75 行工作流 YAML。企业 runner 上的 PR 作业与 Windows 作业的缓存行为未变(只有提供 pnpm 的那一行改用了 action),serial-linux 继续生产只恢复不上传作业所消费的缓存键。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ee12e9768..6813b75c56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,14 +52,14 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack and install dependencies - run: | - corepack enable - pnpm install --frozen-lockfile + - name: Install (immutable) + run: pnpm install --frozen-lockfile - name: Run static gates env: @@ -98,13 +98,14 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack, install dependencies, and prepare bubblewrap + - name: Install dependencies and prepare bubblewrap run: | - corepack enable pnpm install --frozen-lockfile & install_pid=$! bash scripts/prepare-ci-bubblewrap.sh & @@ -158,13 +159,14 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-full- + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack, install dependencies, and prepare bubblewrap + - name: Install dependencies and prepare bubblewrap run: | - corepack enable pnpm install --frozen-lockfile & install_pid=$! bash scripts/prepare-ci-bubblewrap.sh & @@ -291,17 +293,17 @@ jobs: reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + - uses: pnpm/action-setup@v4 + + # Extracting the many-file pnpm store cache is slower than a clean install, + # and saving it adds more latency after gates, so this job runs cache-less. - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - # Extracting the many-file pnpm store cache is slower than a clean install, - # and saving it adds more latency after gates. - - name: Enable corepack and install (immutable) + - name: Install (immutable) shell: pwsh - run: | - corepack enable - pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile - name: Run blocking and observational Windows gates concurrently shell: pwsh @@ -374,13 +376,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack (pnpm) - run: corepack enable - - name: Install (immutable) run: pnpm install --frozen-lockfile @@ -406,14 +407,12 @@ jobs: reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Enable corepack (pnpm) - shell: pwsh - run: corepack enable - # Master refreshes the small cache that pull requests restore without # putting package-store extraction back on the Windows critical path. - uses: actions/cache@v4 diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 51dfe06f1c..0d14914bfd 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -53,13 +53,12 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 with: node-version: 24 - - name: Enable corepack (pnpm) - run: corepack enable - - name: Install (immutable) run: pnpm install --frozen-lockfile From e56afd718d324009712efc67bd02cf204d257b24 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 13:45:04 +0800 Subject: [PATCH 47/70] test(pty-local): keep the raw-mode send active until python3 prints its marker The darwin-parity job failed intermittently on the SIGINT test with the operation buffer holding only the echoed command line, never RAW_READY. The harness sets idleSilenceMs to 250, so when a cold python3 start stays silent past that bound the send settles as inferred_idle; PtySendOperation.append then drops all later output, and the marker reaches only the scrollback. Give the harness per-test idleSilenceMs/timeoutMs overrides and let this scenario raise both above interpreter startup latency, so the readiness marker lands inside the send it belongs to. waitForOutput's own deadline and the test timeout grow to match the new bounds. The product timings are unchanged; the pty Agent Note records why a test that waits on an operation must outlast the child's startup. --- ...26-07-16-persistent-pty-sessions.i18n.yaml | 6 ++--- .../2026-07-16-persistent-pty-sessions.md | 2 ++ .../2026-07-16-persistent-pty-sessions.zh.md | 2 ++ packages/pty/pty-local/tests/local.spec.ts | 23 ++++++++++++++----- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index f7e242b78c..a6877c35ab 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -1,6 +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 -2026-07-16-persistent-pty-sessions.md: 148d4a2f47689e38a3ec83a7a41e4f75c4b73d95 -2026-07-16-persistent-pty-sessions.zh.md: 9a9d9cd4b0f61e8abaf011996ecd8739d13851f8 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +2026-07-16-persistent-pty-sessions.md: 72d903828f8152009a2dd3433d4f7c86b7382ba6 +2026-07-16-persistent-pty-sessions.zh.md: 27dce02618ab7908f38a6b613c109fcbb4b3854c diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 148d4a2f47..72d903828f 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -80,6 +80,8 @@ On macOS there is no exact syscall tier. Output silence returns `inferred_idle` Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.tasks`, signal the foreground group, or inspect from another session. +Once a send settles under any tier, `PtySendOperation.append` stops accepting output, so later child output reaches only the scrollback. A test that waits for a marker on the operation must therefore set `idleSilenceMs` and `timeoutMs` above the child's own startup latency; interpreter startup on a loaded macOS runner otherwise ends the send before the marker is printed. + `node-pty` data notifications feed one terminal parser. Parser carry state handles control sequences and a trailing carriage return split across callbacks, so a divided CRLF produces one newline rather than a pagination-changing blank line. The implementation normalizes line-oriented output, but it does not promise correct interaction with a full-screen application. ### Model-visible output and durability diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 9a9d9cd4b0..27dce02618 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -80,6 +80,8 @@ macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 ` Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。 +一次 send 在任一层级 settle 之后,`PtySendOperation.append` 就不再接受输出,此后子进程的输出只会进入 scrollback。因此,在 operation 上等待标记的测试必须把 `idleSilenceMs` 与 `timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。 + `node-pty` data 通知进入同一个终端 parser。parser 的 carry state 会处理跨 callback 的控制序列和位于 callback 末尾的回车;因此,即使 CRLF 被拆开,也只会生成一个换行,而不会产生改变分页的空行。实现会规范化行式输出,但不承诺正确操作全屏应用。 ### 模型可见输出与持久性 diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 6ba3a95757..2f60cf1918 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -39,7 +39,10 @@ function stubAgent(ctx: Context, rawId: string): Agent { } } -async function harness(mode: 'danger-full-access' | 'workspace-write') { +async function harness( + mode: 'danger-full-access' | 'workspace-write', + overrides: { idleSilenceMs?: number; timeoutMs?: number } = {}, +) { const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-')) roots.push(root) const ctx = new Context() @@ -51,8 +54,8 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') { const fiber = await ctx.plugin(ptyLocal, { pollIntervalMs: 10, exactProbeAfterMs: 20, - idleSilenceMs: 250, - timeoutMs: 2000, + idleSilenceMs: overrides.idleSilenceMs ?? 250, + timeoutMs: overrides.timeoutMs ?? 2_000, disposeGraceMs: 500, scrollbackLines: 100, scrollbackMaxBytes: 32_768, @@ -63,8 +66,10 @@ async function harness(mode: 'danger-full-access' | 'workspace-write') { return { ctx, root, agent, fiber, sandbox: ctx.sandbox as PassthroughSandbox } } +// PtySendOperation.append drops output once the operation settles, so this only +// observes a marker the child prints while the send is still active. async function waitForOutput(operation: PtySendOperation, expected: string): Promise { - const deadline = Date.now() + 2_000 + const deadline = Date.now() + 5_000 let output = '' while (!output.includes(expected) && Date.now() < deadline) { output += operation.readOutput().delta @@ -132,7 +137,13 @@ describe('pty-local real shell', () => { }, 10_000) it('cancels a raw-mode foreground process with a real SIGINT', async () => { - const { ctx, agent } = await harness('danger-full-access') + // A cold `python3` start can stay silent for longer than the 250 ms default + // this harness uses, which settles the send as inferred_idle before the + // interpreter prints its readiness marker; the marker then reaches only the + // scrollback and waitForOutput sees the echoed command line alone. Raise the + // silence bound, and the absolute bound above it, so process startup cannot + // end the send it belongs to. + const { ctx, agent } = await harness('danger-full-access', { idleSilenceMs: 4_000, timeoutMs: 6_000 }) const created = await ctx.pty.spawn(agent, { type: 'shell' }) const controller = new AbortController() const ready = 'RAW_READY' @@ -155,5 +166,5 @@ describe('pty-local real shell', () => { expect(after.viewport).toContain('AFTER_SIGINT') expect(after.waitReason).toBe('stdin_read') await ctx.pty.kill(agent, created.sessionId) - }, 10_000) + }, 20_000) }) From f12ea986cb46a414a276ba4d50c25d61aae12bb5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:04:05 +0800 Subject: [PATCH 48/70] ci: share one Wine Windows gate script between CI and an optional local gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/wine-windows-gates.sh owns the whole lane — workspace snapshot, concurrent Node/wineboot/install provisioning, entrypoint resolution, the vue link, smoke, and the two concurrent blocking gates — so the ci.yml windows job shrinks to runner provisioning plus one script call, and pnpm run check:windows-wine runs the identical script locally. The local path never mutates the working tree, persists the Wine prefix and the checksum-verified Windows Node zip under .cache/wine-windows/, and falls back to the cached zip offline. AGENTS.md ceiling 1680 -> 1695 for the one-line command entry. --- ...27-wine-windows-gates-experiment.i18n.yaml | 4 +- ...026-07-27-wine-windows-gates-experiment.md | 2 + ...-07-27-wine-windows-gates-experiment.zh.md | 2 + .github/workflows/ci.yml | 164 +++------------ AGENTS.md | 1 + package.json | 1 + scripts/doc-budgets.manifest.json | 2 +- scripts/wine-windows-gates.sh | 187 ++++++++++++++++++ 8 files changed, 218 insertions(+), 145 deletions(-) create mode 100755 scripts/wine-windows-gates.sh diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml index 8b8b736a99..cc4c64aae4 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml @@ -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/process/2026-07-27-wine-windows-gates-experiment.md -2026-07-27-wine-windows-gates-experiment.md: aab8aecdfca06c1f15641044a071015f543a84b6 -2026-07-27-wine-windows-gates-experiment.zh.md: 5239b185e1e0c63aa626ee3f20f3f298c0c8579d +2026-07-27-wine-windows-gates-experiment.md: 918d4ab4a1974c6f8ea4bebeb48025aeecf0ef57 +2026-07-27-wine-windows-gates-experiment.zh.md: e1de3395ccd59307b520cb59210923f7fe1a21fd diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md index aab8aecdfc..918d4ab4a1 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md @@ -18,6 +18,8 @@ Dependencies install natively on Linux with `supportedArchitectures` extended to The lane holds the wall clock of the Linux CI jobs through four levers: the master-refreshed pnpm store cache (restore-only, same key as the Linux jobs), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image, seeded from master by the `wine apt cache` job so every pull request restores from the default-branch scope. +The gate logic lives in one script, [scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh): the ci.yml job provisions runner state (caches, apt Wine) and calls it, and the optional local gate `pnpm run check:windows-wine` runs the identical script on a developer machine that has Wine installed — one implementation, so local reproduction of a red CI lane needs no translation between environments. The script never mutates the working tree: it snapshots tracked plus untracked-unignored files into a scratch directory, applies the Wine-specific pnpm overrides to the snapshot only, and installs there against the shared store; the Wine prefix and the checksum-verified Windows Node zip persist under `.cache/wine-windows/` so local reruns skip provisioning, with an offline fallback to the newest cached zip when nodejs.org is unreachable. + Four environment constraints shape the job, each found as a red run: Ubuntu's `wine64` package alone puts nothing on PATH (install `wine`, the dispatcher); Node under Wine cannot attach stdio to the Actions runner's pipes (`Socket open EBADF` at bootstrap — every invocation routes stdio through a file); Wine does not realpath pnpm's isolated-layout Unix symlinks (the hoisted layout above); and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate). ## Measured results diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md index 5239b185e1..e1de3395cc 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -18,6 +18,8 @@ Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表 该通道靠四个杠杆保持 Linux CI 作业的墙钟:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。 +门禁逻辑集中在一个脚本里,[scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh):ci.yml 作业只供给 runner 状态(缓存、apt Wine)然后调用它,可选的本地门禁 `pnpm run check:windows-wine` 在装有 Wine 的开发机上运行同一个脚本——单一实现,因此本地复现红色 CI 通道不需要在环境之间做任何转译。脚本从不改动工作树:把被跟踪加未跟踪未忽略的文件快照进一个临时目录,只对快照施加 Wine 特有的 pnpm 覆盖,并在那里对着共享 store 安装;Wine prefix 与校验和验证过的 Windows Node zip 持久存放在 `.cache/wine-windows/` 下,本地重跑跳过供给,nodejs.org 不可达时回退到最新的已缓存 zip。 + 四条环境约束塑造了该作业,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到 Actions runner 的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。 ## 实测结果 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19deb87034..da19a514fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -286,20 +286,16 @@ jobs: # Windows Node under Wine on standard hosted Linux. The master # serial-windows job below keeps the complete native-kernel inventory — # including the observational portability gates this lane does not run — - # on real windows-2025. Direct tool entrypoints stand in for pnpm's cmd - # shims, which a Linux-side install does not create; layout, fidelity - # limits, and measured timings live in + # on real windows-2025. This job only provisions runner state (caches, + # apt); scripts/wine-windows-gates.sh owns the gate logic and is the same + # script the optional local gate `pnpm run check:windows-wine` runs. + # Layout, fidelity limits, and measured timings live in # .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md windows: if: github.event_name == 'pull_request' runs-on: ubuntu-latest name: windows node 24 / wine blocking timeout-minutes: 15 - env: - WINEDEBUG: '-all' - WINEARCH: win64 - # Skip Wine Mono / Gecko installers: Node needs neither. - WINEDLLOVERRIDES: 'mscoree,mshtml=' steps: - uses: actions/checkout@v6 with: @@ -328,144 +324,28 @@ jobs: path: ~/wine-debs key: ${{ steps.wine-cache-key.outputs.key }} - - name: Install dependencies and provision Wine concurrently + # Runner provisioning only — a developer machine installs Wine through + # its own package manager; the gate script assumes a wine binary and + # fails loud without one. Wine from the apt cache when present; else + # download the full dependency closure once and keep it for the next + # run. The `wine` dispatcher package (not bare `wine64`) is what puts a + # binary on PATH. + - name: Install Wine run: | - corepack enable - - # Windows-lane install-time overrides. supportedArchitectures - # additionally materializes the win32-x64 platform packages - # (@esbuild/win32-x64, rolldown and rollup MSVC bindings) the - # Windows toolchain resolves at runtime; nodeLinker: hoisted lays - # node_modules out flat with real files because Windows Node under - # Wine does not realpath pnpm's isolated-layout symlinks. Neither - # override is recorded in the lockfile, so --frozen-lockfile stays - # valid. --ignore-scripts skips Linux lifecycle scripts no gate in - # this lane loads; the win32 binaries ship prebuilt. - cat >> pnpm-workspace.yaml <<'EOF' - - nodeLinker: hoisted - supportedArchitectures: - os: [current, win32] - cpu: [current, x64] - EOF - - pnpm install --frozen-lockfile --ignore-scripts & - install_pid=$! - - provision_wine() { - set -euo pipefail - # Wine from the apt cache when present; else download the full - # dependency closure once and keep it for the next run. The - # `wine` dispatcher package (not bare `wine64`) is what puts a - # binary on PATH. - if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then - sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb - else - sudo apt-get update - sudo apt-get install -y --no-install-recommends --download-only wine - mkdir -p "$HOME/wine-debs" - cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true - sudo apt-get install -y --no-install-recommends wine - fi - WINE_BIN='' - for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do - if [ -n "$candidate" ] && [ -x "$candidate" ]; then WINE_BIN="$candidate"; break; fi - done - [ -n "$WINE_BIN" ] || { echo '::error::no wine binary found after install'; exit 1; } - echo "WINE_BIN=$WINE_BIN" >> "$GITHUB_ENV" - - # Windows Node for the repo's primary line, checksum-verified - # against the same dist directory. - version=$(curl -fsSL https://nodejs.org/dist/index.json \ - | jq -r --arg p "v${PRIMARY_NODE_VERSION}." '[.[] | select(.version | startswith($p))][0].version') - echo "Windows Node: $version" - curl -fsSL -o "$RUNNER_TEMP/node-win.zip" \ - "https://nodejs.org/dist/${version}/node-${version}-win-x64.zip" - curl -fsSL "https://nodejs.org/dist/${version}/SHASUMS256.txt" \ - | awk -v a="node-${version}-win-x64.zip" '$2 == a { print $1 " '"$RUNNER_TEMP"'/node-win.zip" }' \ - | sha256sum --check - - unzip -q "$RUNNER_TEMP/node-win.zip" -d "$RUNNER_TEMP/node-win" - echo "NODE_WIN=$RUNNER_TEMP/node-win/node-${version}-win-x64/node.exe" >> "$GITHUB_ENV" - - "$WINE_BIN" wineboot --init || true - wineserver -w || true - } - provision_wine & - wine_pid=$! - - install_status=0 - wait "$install_pid" || install_status=$? - wine_status=0 - wait "$wine_pid" || wine_status=$? - if (( install_status != 0 )); then exit "$install_status"; fi - exit "$wine_status" - - - name: Resolve entrypoints, link vue, smoke Windows Node - run: | - # Node under Wine cannot attach stdio to the Actions runner's pipes - # (Socket open EBADF at bootstrap), so every invocation runs through - # this wrapper: stdio to a regular file, replayed after exit. - cat > "$RUNNER_TEMP/wine-node.sh" <<'SH' - #!/usr/bin/env bash - set -u - log="$1"; shift - "$WINE_BIN" "$NODE_WIN" "$@" < /dev/null > "$log" 2>&1 - status=$? - tail -n 300 "$log" - exit "$status" - SH - chmod +x "$RUNNER_TEMP/wine-node.sh" - - resolve() { - local name="$1"; shift - for p in "$@"; do - if [ -f "$p" ]; then echo "$name=$PWD/$p" >> "$GITHUB_ENV"; return 0; fi - done - echo "::error::$name not found at any of: $*"; return 1 - } - resolve TSC_JS node_modules/typescript/bin/tsc - resolve TSDOWN_JS node_modules/tsdown/dist/run.mjs - resolve VITEPRESS_JS website/node_modules/vitepress/bin/vitepress.js node_modules/vitepress/bin/vitepress.js - - # VitePress links vue into the site's node_modules at build time; - # Wine cannot CREATE Windows symlinks (ENOTSUP) but follows - # pre-existing Unix ones, so lay the link down host-side. - if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then - mkdir -p website/node_modules - ln -s ../../node_modules/vue website/node_modules/vue + if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then + sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb + else + sudo apt-get update + sudo apt-get install -y --no-install-recommends --download-only wine + mkdir -p "$HOME/wine-debs" + cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true + sudo apt-get install -y --no-install-recommends wine fi - "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version" - - # The two blocking surfaces run concurrently, the same shape run-gates - # gives ci-windows-blocking on native Windows: `build` = tsc -b then - # tsdown, `production site` = the VitePress build. Both statuses are - # captured so one failure cannot hide the other's result. - - name: Run blocking Windows gates concurrently under Wine + - name: Run the Wine Windows gates run: | - build_gate() { - "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsc.log" "$TSC_JS" -b --pretty false || return $? - "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsdown.log" "$TSDOWN_JS" - } - site_gate() { - cd website - "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/site.log" "$VITEPRESS_JS" build . - } - start=$SECONDS - build_gate > "$RUNNER_TEMP/build-gate.out" 2>&1 & - build_pid=$! - site_gate > "$RUNNER_TEMP/site-gate.out" 2>&1 & - site_pid=$! - build_status=0 - wait "$build_pid" || build_status=$? - site_status=0 - wait "$site_pid" || site_status=$? - echo "== build gate (exit $build_status, $((SECONDS - start))s elapsed) ==" - tail -n 120 "$RUNNER_TEMP/build-gate.out" - echo "== production site gate (exit $site_status, $((SECONDS - start))s elapsed) ==" - tail -n 120 "$RUNNER_TEMP/site-gate.out" - if (( build_status != 0 )); then exit "$build_status"; fi - exit "$site_status" + corepack enable + bash scripts/wine-windows-gates.sh - name: Shut down wineserver if: always() diff --git a/AGENTS.md b/AGENTS.md index d6d0365dfa..0749bf5043 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,7 @@ pnpm run typecheck pnpm run lint pnpm run duplication # cross-file TypeScript clone detection pnpm run build # tsc emits lib/types, tsdown bundles runtime +pnpm run check:windows-wine # optional: CI's blocking Windows gates locally (needs wine) pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gates.ts pnpm run website:build # VitePress build (doubles as dead-link check) diff --git a/package.json b/package.json index 90194a57d3..a1b3b1fdca 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", + "check:windows-wine": "bash scripts/wine-windows-gates.sh", "check:node-compat": "tsx scripts/run-gates.ts node-compat", "knip": "knip --treat-config-hints-as-errors", "publint": "tsx scripts/publint-all.ts", diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index f1d40380e5..674d57ad97 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1680, + "AGENTS.md": 1695, "docs/AGENTS.md": 1150, "docs/architecture.md": 1800, "docs/cordis-primer.md": 600, diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh new file mode 100755 index 0000000000..97f09bb9b3 --- /dev/null +++ b/scripts/wine-windows-gates.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# Run the blocking Windows gates (workspace build, production site) with real +# win-x64 Node.js under Wine — the same script the pull-request `windows` job +# in ci.yml executes and the optional local gate `pnpm run check:windows-wine` +# wraps. Owning rationale, fidelity limits, and measured timings: +# .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md +# +# The working tree is never mutated: tracked plus untracked-unignored files +# are snapshotted into a scratch directory, the Wine-specific pnpm overrides +# (hoisted layout, win32-x64 platform packages) are appended to the SNAPSHOT's +# pnpm-workspace.yaml, and the install and gates run there against the shared +# pnpm store. The Wine prefix and the checksum-verified Windows Node zip +# persist in .cache/wine-windows/ so reruns skip provisioning. +# +# Environment: DSH_WINE_NODE_MAJOR (default $PRIMARY_NODE_VERSION, then 24) +# picks the Windows Node line; DSH_WINE_GATE_CACHE_DIR relocates the cache; +# DSH_WINE_GATE_KEEP=1 preserves the scratch tree for inspection. + +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +node_major="${DSH_WINE_NODE_MAJOR:-${PRIMARY_NODE_VERSION:-24}}" +cache_dir="${DSH_WINE_GATE_CACHE_DIR:-$repo_root/.cache/wine-windows}" + +export WINEDEBUG='-all' +export WINEARCH=win64 +# Skip Wine Mono / Gecko installers: Node needs neither. +export WINEDLLOVERRIDES='mscoree,mshtml=' +export WINEPREFIX="$cache_dir/prefix" + +# ---- preflight: fail loud before any expensive work -------------------- +wine_bin='' +for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do + if [ -n "$candidate" ] && [ -x "$candidate" ]; then wine_bin="$candidate"; break; fi +done +missing=() +[ -n "$wine_bin" ] || missing+=('wine (apt: wine | brew: wine-stable)') +command -v curl > /dev/null || missing+=('curl') +command -v unzip > /dev/null || missing+=('unzip') +if ! command -v pnpm > /dev/null; then corepack enable > /dev/null 2>&1 || true; fi +command -v pnpm > /dev/null || missing+=('pnpm (corepack enable)') +if (( ${#missing[@]} > 0 )); then + printf 'wine-windows-gates: missing required tool: %s\n' "${missing[@]}" >&2 + exit 1 +fi + +scratch="$(mktemp -d "${TMPDIR:-/tmp}/dsh-wine-gates.XXXXXX")" +cleanup() { + wineserver -k > /dev/null 2>&1 || true + if [ "${DSH_WINE_GATE_KEEP:-0}" = '1' ]; then + echo "wine-windows-gates: scratch tree kept at $scratch" + else + rm -rf "$scratch" + fi +} +trap cleanup EXIT +mkdir -p "$cache_dir" "$scratch/logs" + +# ---- provision Windows Node, boot Wine, snapshot + install concurrently ---- +provision_node() { + # Latest release of the primary line, checksum-verified against the same + # dist directory. Offline runs fall back to the newest cached zip, loudly. + local version zip + version="$(curl -fsSL --max-time 30 https://nodejs.org/dist/index.json 2> /dev/null \ + | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const v=JSON.parse(d).find(r=>r.version.startsWith('v$node_major.'));if(v)console.log(v.version)})" \ + || true)" + if [ -n "$version" ]; then + zip="$cache_dir/node-$version-win-x64.zip" + if [ ! -f "$zip" ]; then + curl -fsSL -o "$zip.tmp" "https://nodejs.org/dist/$version/node-$version-win-x64.zip" + curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \ + | awk -v a="node-$version-win-x64.zip" -v f="$zip.tmp" '$2 == a { print $1 " " f }' \ + | sha256sum --check - > /dev/null + mv "$zip.tmp" "$zip" + fi + else + zip="$(ls -t "$cache_dir"/node-v"$node_major".*-win-x64.zip 2> /dev/null | head -1 || true)" + [ -n "$zip" ] || { echo "wine-windows-gates: nodejs.org unreachable and no cached Windows Node v$node_major zip in $cache_dir" >&2; exit 1; } + echo "wine-windows-gates: nodejs.org unreachable; using cached $(basename "$zip")" >&2 + fi + unzip -q -o "$zip" -d "$scratch/node-win" + echo "$scratch/node-win/$(basename "$zip" .zip)/node.exe" > "$scratch/node-win-path" +} + +boot_wine() { + "$wine_bin" wineboot --init > /dev/null 2>&1 || true + wineserver -w || true +} + +snapshot_and_install() { + # Tracked + untracked-unignored files, minus agent-session litter; the + # existence filter drops paths staged as deleted. Then the Wine-specific + # install-time overrides go on the SNAPSHOT only: hoisted because Windows + # Node under Wine does not realpath pnpm's isolated-layout symlinks, and + # win32-x64 so the Windows esbuild/rolldown/rollup binaries materialize. + # Neither is recorded in the lockfile, so --frozen-lockfile stays valid; + # --ignore-scripts skips host lifecycle scripts no gate loads. + git -C "$repo_root" ls-files -z --cached --others --exclude-standard -- . ':!:.claude' ':!:.codex' \ + | while IFS= read -r -d '' file; do [ -e "$repo_root/$file" ] && printf '%s\0' "$file"; done \ + | tar -C "$repo_root" --null --files-from=- -cf - \ + | tar -C "$scratch/tree" -xf - + cat >> "$scratch/tree/pnpm-workspace.yaml" << 'EOF' + +nodeLinker: hoisted +supportedArchitectures: + os: [current, win32] + cpu: [current, x64] +EOF + (cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \ + || { tail -40 "$scratch/logs/install.log" >&2; return 1; } +} + +mkdir "$scratch/tree" +start=$SECONDS +provision_node & node_pid=$! +boot_wine & wine_pid=$! +snapshot_and_install & install_pid=$! +for task_pid in "$node_pid" "$wine_pid" "$install_pid"; do wait "$task_pid"; done +node_win="$(cat "$scratch/node-win-path")" +echo "wine-windows-gates: provisioned in $((SECONDS - start))s (wine $("$wine_bin" --version 2> /dev/null), node $(basename "$(dirname "$node_win")"))" + +# ---- resolve entrypoints, lay the vue link, smoke ------------------------ +# Node under Wine cannot attach stdio to pipes the caller owns (Socket open +# EBADF at bootstrap), so every invocation routes stdio through a file. +wine_node() { + local log="$1" + shift + local status=0 + "$wine_bin" "$node_win" "$@" < /dev/null > "$log" 2>&1 || status=$? + return "$status" +} + +cd "$scratch/tree" +tsc_js='node_modules/typescript/bin/tsc' +tsdown_js='node_modules/tsdown/dist/run.mjs' +vitepress_js='node_modules/vitepress/bin/vitepress.js' +[ -f "$vitepress_js" ] || vitepress_js='website/node_modules/vitepress/bin/vitepress.js' +for entry in "$tsc_js" "$tsdown_js" "$vitepress_js"; do + [ -f "$entry" ] || { echo "wine-windows-gates: expected entrypoint missing after hoisted install: $entry" >&2; exit 1; } +done +# VitePress links vue into the site's node_modules at build time; Wine cannot +# CREATE Windows symlinks (ENOTSUP) but follows pre-existing Unix ones. +if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then + mkdir -p website/node_modules + ln -s ../../node_modules/vue website/node_modules/vue +fi + +wine_node "$scratch/logs/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version" +cat "$scratch/logs/smoke.log" +grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; } + +# ---- the two blocking surfaces, concurrently ------------------------------ +# The same shape run-gates gives ci-windows-blocking on native Windows: +# `build` = tsc -b then tsdown, `production site` = the VitePress build. Both +# statuses are captured so one failure cannot hide the other's result. +build_gate() { + wine_node "$scratch/logs/tsc.log" "$tsc_js" -b --pretty false || return $? + wine_node "$scratch/logs/tsdown.log" "$tsdown_js" +} +site_gate() { + cd website + wine_node "$scratch/logs/site.log" "../$vitepress_js" build . +} + +start=$SECONDS +build_gate & build_pid=$! +site_gate & site_pid=$! +build_status=0 +wait "$build_pid" || build_status=$? +site_status=0 +wait "$site_pid" || site_status=$? +elapsed=$((SECONDS - start)) + +report() { + local label="$1" status="$2" + shift 2 + if (( status == 0 )); then + echo "wine-windows-gates: PASS $label (${elapsed}s window)" + else + echo "== FAILED $label (exit $status) ==" >&2 + for log in "$@"; do tail -n 200 "$log" >&2 || true; done + fi +} +report 'build (tsc -b, tsdown)' "$build_status" "$scratch/logs/tsc.log" "$scratch/logs/tsdown.log" +report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log" +if (( build_status != 0 )); then exit "$build_status"; fi +exit "$site_status" From 8be70e72d216535f0ae1e8fd91aacb9b66af0626 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:15:37 +0800 Subject: [PATCH 49/70] docs: scope the local Wine gate to Windows-failure diagnosis only AGENTS.md and the Wine Agent Note pair state the run policy: CI owns the everyday win32 signal; run check:windows-wine only while investigating a known Windows-related failure, and pre-push selection never includes it. --- .../2026-07-27-wine-windows-gates-experiment.i18n.yaml | 4 ++-- .../process/2026-07-27-wine-windows-gates-experiment.md | 2 +- .../process/2026-07-27-wine-windows-gates-experiment.zh.md | 2 +- AGENTS.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml index cc4c64aae4..5f939a7b67 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml @@ -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/process/2026-07-27-wine-windows-gates-experiment.md -2026-07-27-wine-windows-gates-experiment.md: 918d4ab4a1974c6f8ea4bebeb48025aeecf0ef57 -2026-07-27-wine-windows-gates-experiment.zh.md: e1de3395ccd59307b520cb59210923f7fe1a21fd +2026-07-27-wine-windows-gates-experiment.md: fd36a3322b0d63a0fa605ecfcec95c7522b43b25 +2026-07-27-wine-windows-gates-experiment.zh.md: 97a54161bb851785a9108cc45d59617e1a449e68 diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md index 918d4ab4a1..fd36a3322b 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md @@ -18,7 +18,7 @@ Dependencies install natively on Linux with `supportedArchitectures` extended to The lane holds the wall clock of the Linux CI jobs through four levers: the master-refreshed pnpm store cache (restore-only, same key as the Linux jobs), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image, seeded from master by the `wine apt cache` job so every pull request restores from the default-branch scope. -The gate logic lives in one script, [scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh): the ci.yml job provisions runner state (caches, apt Wine) and calls it, and the optional local gate `pnpm run check:windows-wine` runs the identical script on a developer machine that has Wine installed — one implementation, so local reproduction of a red CI lane needs no translation between environments. The script never mutates the working tree: it snapshots tracked plus untracked-unignored files into a scratch directory, applies the Wine-specific pnpm overrides to the snapshot only, and installs there against the shared store; the Wine prefix and the checksum-verified Windows Node zip persist under `.cache/wine-windows/` so local reruns skip provisioning, with an offline fallback to the newest cached zip when nodejs.org is unreachable. +The gate logic lives in one script, [scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh): the ci.yml job provisions runner state (caches, apt Wine) and calls it, and the optional local gate `pnpm run check:windows-wine` runs the identical script on a developer machine that has Wine installed — one implementation, so local reproduction of a red CI lane needs no translation between environments. The local gate is a diagnosis tool, not a routine check: run it only when investigating a known Windows-related failure; CI owns the everyday win32 signal, and [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) never selects it. The script never mutates the working tree: it snapshots tracked plus untracked-unignored files into a scratch directory, applies the Wine-specific pnpm overrides to the snapshot only, and installs there against the shared store; the Wine prefix and the checksum-verified Windows Node zip persist under `.cache/wine-windows/` so local reruns skip provisioning, with an offline fallback to the newest cached zip when nodejs.org is unreachable. Four environment constraints shape the job, each found as a red run: Ubuntu's `wine64` package alone puts nothing on PATH (install `wine`, the dispatcher); Node under Wine cannot attach stdio to the Actions runner's pipes (`Socket open EBADF` at bootstrap — every invocation routes stdio through a file); Wine does not realpath pnpm's isolated-layout Unix symlinks (the hoisted layout above); and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate). diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md index e1de3395cc..97a54161bb 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -18,7 +18,7 @@ Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表 该通道靠四个杠杆保持 Linux CI 作业的墙钟:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。 -门禁逻辑集中在一个脚本里,[scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh):ci.yml 作业只供给 runner 状态(缓存、apt Wine)然后调用它,可选的本地门禁 `pnpm run check:windows-wine` 在装有 Wine 的开发机上运行同一个脚本——单一实现,因此本地复现红色 CI 通道不需要在环境之间做任何转译。脚本从不改动工作树:把被跟踪加未跟踪未忽略的文件快照进一个临时目录,只对快照施加 Wine 特有的 pnpm 覆盖,并在那里对着共享 store 安装;Wine prefix 与校验和验证过的 Windows Node zip 持久存放在 `.cache/wine-windows/` 下,本地重跑跳过供给,nodejs.org 不可达时回退到最新的已缓存 zip。 +门禁逻辑集中在一个脚本里,[scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh):ci.yml 作业只供给 runner 状态(缓存、apt Wine)然后调用它,可选的本地门禁 `pnpm run check:windows-wine` 在装有 Wine 的开发机上运行同一个脚本——单一实现,因此本地复现红色 CI 通道不需要在环境之间做任何转译。该本地门禁是诊断工具而非例行检查:仅在排查已知的 Windows 相关失败时运行;日常 win32 信号归 CI 所有,[dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 也从不选择它。脚本从不改动工作树:把被跟踪加未跟踪未忽略的文件快照进一个临时目录,只对快照施加 Wine 特有的 pnpm 覆盖,并在那里对着共享 store 安装;Wine prefix 与校验和验证过的 Windows Node zip 持久存放在 `.cache/wine-windows/` 下,本地重跑跳过供给,nodejs.org 不可达时回退到最新的已缓存 zip。 四条环境约束塑造了该作业,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到 Actions runner 的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。 diff --git a/AGENTS.md b/AGENTS.md index 0749bf5043..dabf2b9945 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ pnpm run typecheck pnpm run lint pnpm run duplication # cross-file TypeScript clone detection pnpm run build # tsc emits lib/types, tsdown bundles runtime -pnpm run check:windows-wine # optional: CI's blocking Windows gates locally (needs wine) +pnpm run check:windows-wine # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gates.ts pnpm run website:build # VitePress build (doubles as dead-link check) From e8cbf6cc723df323cd316d6dce6395d0f3da2412 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:22:01 +0800 Subject: [PATCH 50/70] fix: remove orig --- .../src/client/sessions/service.ts.orig | 590 ------------------ .../ui-workspace/src/client/index.ts.orig | 98 --- .../ui-workspace/src/client/tree.ts.orig | 321 ---------- 3 files changed, 1009 deletions(-) delete mode 100644 packages/client/runtime/src/client/sessions/service.ts.orig delete mode 100644 packages/client/ui-workspace/src/client/index.ts.orig delete mode 100644 packages/client/ui-workspace/src/client/tree.ts.orig diff --git a/packages/client/runtime/src/client/sessions/service.ts.orig b/packages/client/runtime/src/client/sessions/service.ts.orig deleted file mode 100644 index deb1616a8a..0000000000 --- a/packages/client/runtime/src/client/sessions/service.ts.orig +++ /dev/null @@ -1,590 +0,0 @@ -/** - * SessionsService: root sessions service — list snapshot store (manager - * projection; carries `current`, the persisted selection every - * session-scoped surface keys off — migrated here from ui-layout per the - * slot-parity design), Agent scope tree (mintScope pattern: no-op plugin - * Fiber + ctx.extend scope tag; one scope per session, agent id === session - * id), stable SessionBinding cache, ancestry walk. - * - * Scope lifecycle is stage-driven: a scope is minted lazily on first - * resolution (pure — resolution has no side effects and is render-safe); - * the event window and deferred teardown key off the STAGED session, which - * follows `list.current` exactly. Staging is the open signal: the window - * opens ⟺ the session is on stage (today the stage is `current`; the staged - * state can widen to a multi-pane list later). A session leaving the list - * tears its scope down immediately unless it is the staged one, whose scope - * survives frozen (read-only view) until the stage moves on. - */ -import type { Context, Fiber } from 'cordis' -import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' -import type { - HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, -} from '@deepseek-ai/dsh-client-ui-slots' -import type { SnapshotStore } from '../contract/store.ts' -import { createSnapshotStore } from '../contract/store.ts' -import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' -import { SessionManager } from './manager.ts' -import type { SessionListPhase } from './manager.ts' -import type { Session } from './session.ts' - -/** Session list row projected from the host list RPC plus live stream increments. */ -export interface SessionSummary { - id: SessionId - /** Latest durable log-backed title, absent until the host projects one. */ - title?: string - /** Human-facing label: durable title, project basename, then session id. */ - displayTitle: string - cwd?: string - parentId?: SessionId - running: boolean - /** - * Empty-log bit (host summary derivation mirror). List surfaces hide blank - * sessions; New Session reuses a blank one targeting the same workspace. - * Filtering stays with the consumer — the store carries every row. - */ - blank: boolean - updatedAt: number -} - -/** - * Session list store shape. `current` rides the same snapshot (arbitrated: - * the single useSessions standard hook reads list and selection together — - * sidebar highlighting and SessionProvider share one fact source). - */ -export interface SessionListState { - ids: SessionId[] - byId: Record - current: SessionId | undefined - /** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */ - phase: SessionListPhase -} - -/** Structured session-create failure. */ -export class SessionCreateError extends Error { - override readonly name = 'SessionCreateError' - - /** - * @param rpcError - Host business or folded transport error. - * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation. - */ - constructor( - readonly rpcError: RpcError, - readonly requestedSessionId: SessionId | undefined, - ) { - super(`session create failed: ${rpcError.code}: ${rpcError.message}`) - } -} - -/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */ -export interface SessionBinding { - readonly sessionId: SessionId - readonly session: Session - readonly ctx: Context -} - -// Scope primitives live in ../agents/scope.ts (the client mirror of host -// dsh-scope, keyed by Agent identity); re-exported here so existing -// consumers keep their import site. -export { scopeOf } from '../agents/scope.ts' - -/** - * Workspace display title of a session cwd: the path's last non-empty - * segment (both separators accepted; trailing separators ignored), or '' - * for separator-only paths — callers own their fallback (session id, raw - * cwd, default-directory copy). The repo-wide single basename derivation — - * every surface naming a workspace (picker rows, toggle labels, list titles) - * calls this instead of re-splitting paths. - * @param cwd - workspace directory path. - * @returns basename title, or '' when no non-empty segment exists. - */ -export function workspaceTitleOf(cwd: string): string { - return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? '' -} - -/** - * Display title projection: durable title, project directory basename, then - * the raw id. - */ -function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string { - if (title !== undefined) return title - if (cwd !== undefined && cwd !== '') { - const base = workspaceTitleOf(cwd) - if (base !== '') return base - } - return id -} - -interface ScopeRecord { - fiber: Fiber - ctx: Context - binding: SessionBinding - /** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */ - provideInfo: SessionProvideInfo -} - -/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */ -export interface SessionProvideContribution { - /** Bare observable sources, keyed by hook base name ('input' → useInput). */ - hooks?: Record> - /** Stable plain members (action callbacks etc.), spread into standard props verbatim. */ - props?: Record -} - -/** - * Static declaration plus per-session resolver for one standard-kit - * contribution. The declared names let the renderer construct the same hook - * and prop surface while no session is current. - */ -export interface SessionProvideDescriptor { - /** Hook base names (`input` becomes `useInput`). */ - hooks?: readonly string[] - /** Plain standard-prop names. */ - props?: readonly string[] - /** Resolve every declared member for one definite session. */ - resolve(binding: SessionBinding): SessionProvideContribution -} - -/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */ -export class SessionsService { - /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */ - readonly list: SnapshotStore - /** The object-layer instance cluster and frame dispatch entry. */ - private readonly manager: SessionManager - - /** - * Persisted selection cell (the durable half of `list.current`). Private on - * purpose: reads go through the list snapshot; writes through {@link - * SessionsService.open} / {@link SessionsService.clear}. Projection - * validates it against the live list instead of destructively pruning, so a - * selection survives transient list states (reconnect re-pull) and - * resurfaces when its session returns. - */ - private readonly selection: SnapshotStore<{ sessionId?: SessionId }> - - private readonly scopes = new Map() - /** Registered per-session standard-props providers, in registration order. */ - private readonly providers: SessionProvideDescriptor[] = [] - /** Static no-session projection, rebuilt only when the provider roster changes. */ - private maybeInfo: SessionMaybeProvideInfo - /** - * The staged session id — follows `list.current` exactly, holding its last - * defined value across masked gaps (a transiently absent selection blanks - * `current` without moving the stage, so reconnect re-pulls and removals - * keep the staged scope's frozen view alive until the stage moves on). - */ - private watched: SessionId | undefined - /** Removed-while-staged sessions whose teardown waits for the stage to move away. */ - private readonly deferredRemovals = new Set() - - /** - * @param ctx - client root context (scope fibers mount under it). - * @param api - wire client shared with every Session. - */ - constructor(private readonly rootCtx: Context, api: IApiClient) { - this.selection = createSnapshotStore<{ sessionId?: SessionId }>( - {}, - { persist: { name: 'dsh.sessions.current' } }) - this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId) - this.list = createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'pending', - }) - // The manager owns wire truth; the store is its projection. Manager - // notifications are already microtask-batched. - this.manager.subscribe(() => { this.projectList() }) - // Stage follower: every current write (open() and projection alike) - // re-evaluates staging, so startup restore (persisted selection validated - // by the projection) and reconnect resurfacing open their window with no - // dedicated code path. Safe to run synchronously inside the store notify: - // the follower writes no list state — session.open()'s synchronous prefix - // touches only session-side state and its own microtask-batched notifier. - this.list.subscribe(() => { this.followCurrent() }) - // The runtime's own contribution comes first: useSession rides the same - // provide channel every plugin uses (no renderer special case). - this.providers.push({ - hooks: ['session'], - resolve: binding => ({ hooks: { session: binding.session } }), - }) - this.maybeInfo = this.materializeMaybeProvideInfo() - rootCtx.reflect.provide('sessions', this, undefined) - } - - /** - * Register a per-session standard-props provider: every session-scope slot - * component receives the contributed members as standard props (`hooks` - * sources become `use` selector hooks on the render side; `props` - * spread verbatim). Contributions materialize lazily with the session's - * scope record and die with it. Registration order is resolution order; - * duplicate member names fail loud at materialization. - * @param descriptor - static member roster plus per-session resolver. - * @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops). - */ - provide(descriptor: SessionProvideDescriptor): () => void { - this.providers.push(descriptor) - // Scopes may already exist (boot order: the list lands and resolves - // scopes before later plugins register) — their bundles must include - // every provider by first render, so re-materialize on roster change. - this.rematerializeProvideBundles() - return () => { - const at = this.providers.indexOf(descriptor) - if (at >= 0) this.providers.splice(at, 1) - this.rematerializeProvideBundles() - } - } - - /** Rebuild every live scope's standard-props bundle after a provider roster change. */ - private rematerializeProvideBundles(): void { - this.maybeInfo = this.materializeMaybeProvideInfo() - for (const record of this.scopes.values()) { - record.provideInfo = this.materializeProvideInfo(record.binding) - } - } - - /** Build the static no-session kit and reject duplicate declared names. */ - private materializeMaybeProvideInfo(): SessionMaybeProvideInfo { - const hooks: Record = {} - const props: Record = {} - for (const descriptor of this.providers) { - for (const name of descriptor.hooks ?? []) { - if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) - hooks[name] = undefined - } - for (const name of descriptor.props ?? []) { - if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) - props[name] = undefined - } - } - return { sessionId: undefined, hooks, props } - } - - /** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */ - private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo { - const hooks: Record> = {} - const props: Record = {} - for (const descriptor of this.providers) { - const contribution = descriptor.resolve(binding) - const contributedHooks = contribution.hooks ?? {} - const contributedProps = contribution.props ?? {} - for (const name of Object.keys(contributedHooks)) { - if (!(descriptor.hooks ?? []).includes(name)) { - throw new Error(`sessions.provide: undeclared hook "${name}"`) - } - } - for (const name of Object.keys(contributedProps)) { - if (!(descriptor.props ?? []).includes(name)) { - throw new Error(`sessions.provide: undeclared prop "${name}"`) - } - } - for (const name of descriptor.hooks ?? []) { - const source = contributedHooks[name] - if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`) - if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) - hooks[name] = source - } - for (const name of descriptor.props ?? []) { - if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`) - if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) - props[name] = contributedProps[name] - } - } - return { sessionId: binding.sessionId, hooks, props } - } - - /** - * Select a session as current. Unknown ids fail loud instead of navigating - * nowhere. - * @param id - session id (must exist in the list store). - */ - open(id: SessionId): void { - this.manager.select(id) - } - - /** - * Clear the current selection so the layout shows the no-session empty - * state (new-session affordance and the workspace preselection flow). - * Wipes the persisted selection too — a reload stays on empty until the - * user opens or starts a session. The staged scope keeps its frozen view - * per the masked-gap contract until the next open() moves the stage. - */ - clear(): void { - this.manager.clearSelection() - } - - /** - * Refresh the real Session baseline, reusing an in-flight pull. - * @returns completion of the current or newly started baseline pull. - */ - refresh(): Promise { - return this.manager.refreshList() - } - - /** - * Route a mux stream envelope into the Session object layer. - * @param envelope - validated mux stream envelope. - */ - handleMuxEnvelope(envelope: Parameters[0]): void { - this.manager.handleMuxEnvelope(envelope) - } - - /** - * Route a Host stream envelope into the Session object layer. - * @param envelope - validated Host stream envelope. - */ - handleHostEnvelope(envelope: Parameters[0]): void { - this.manager.handleHostEnvelope(envelope) - } - - /** Rebuild the Session baseline and every opened window after connection. */ - handleConnected(): void { - this.manager.handleConnected() - } - - /** - * Create a session on the host. Resolution guarantee: by the time the - * promise resolves, the created session is in the list store and - * {@link SessionsService.binding} resolves it — callers (New Session - * draft hand-off) may address the scope synchronously, without waiting a - * notifier flush. The synchronous projection below makes this structural - * rather than an accident of microtask ordering. - * @param opts - target workspace or directory and an optional preallocated id. - * @returns the new session id. - * @throws {SessionCreateError} with the requested id. - */ - async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise { - const result = await this.manager.create(opts) - if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId) - this.projectList() - return result.value.sessionId - } - - /** - * Resolve an Agent-scoped context view (use-and-discard). - * @param id - session id (the agent identity — 1:1 same axis). - * @returns scoped ctx, or undefined for a session neither listed nor already scoped. - */ - scope(id: SessionId): Context | undefined { - return this.resolve(id)?.ctx - } - - /** - * Read the Agent scope tag off a context. Service-method seam: fetch - * bundles must reach scope resolution through ctx.sessions — a cross-bundle - * value import of the standalone helper would inline a second module - * instance whose private tag Symbol never matches. - * @param ctx - any client context. - * @returns the session id, or undefined on root contexts. - */ - scopeOf(ctx: Context): SessionId | undefined { - return scopeTagOf(ctx) - } - - /** - * Resolve the business Session behind an Agent-scoped context — the one - * hop every scoped consumer (event listeners, per-session controllers) - * takes from ctx-space into object-space (the client mirror of host - * `agent.session`). Same service-method seam as - * {@link SessionsService.scopeOf}. - * @param ctx - an Agent-scoped context. - * @returns the Session, or undefined when the ctx is untagged or its scope was pruned. - */ - sessionOf(ctx: Context): Session | undefined { - const id = scopeTagOf(ctx) - if (id === undefined) return undefined - return this.scopes.get(id)?.binding.session - } - - /** - * Resolve the stable session binding (scope-addressed assembly feed). Pure - * resolution — no staging, no window side effects. - * @param id - session id. - * @returns binding, or undefined for a session neither listed nor already scoped. - */ - binding(id: SessionId): SessionBinding | undefined { - return this.resolve(id)?.binding - } - - /** - * Resolve the render-layer standard-props bundle (SessionProvider's feed - * through the renderer host; ctx never enters the render layer). Pure - * resolution — render-safe: SessionProvider calls this during render, so no - * staging, no window side effects (StrictMode double-invokes and concurrent - * discarded passes must stay free). - * @param id - session id. - * @returns the provide info, or undefined for a session neither listed nor already scoped. - */ - provideInfo(id: string): SessionProvideInfo | undefined { - return this.resolve(id as SessionId)?.provideInfo - } - - /** - * Resolve the current-session-optional standard kit. Unknown or absent ids - * return the static no-session projection rather than removing hook props. - * @param id - current session id, when selected. - * @returns a definite or no-session provide bundle. - */ - maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { - return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo - } - - /** - * Move the stage to the list's current session: sweep teardowns deferred - * behind the previous occupant and pull the new occupant's history window. - * Staging IS the open signal — the window opens ⟺ the session is on stage - * — and open() is idempotent (an in-flight or completed open no-ops; a - * failed one retries the next time current is touched). - */ - private followCurrent(): void { - const snapshot = this.list.getSnapshot() - const current = snapshot.current - // A masked gap (current blanked while the selection's session is - // transiently absent) holds the stage: tearing down on the gap would - // destroy exactly the frozen scope the mask exists to preserve. - if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return - this.watched = current - this.sweepDeferred() - const record = this.resolve(current) - /* v8 ignore next 3 -- defensive: current is always a listed id (open() - * validates and the projection masks absent selections), so resolve - * cannot miss; kept so a future current writer cannot crash the notify. */ - if (record !== undefined) { - void record.binding.session.open() - } - } - - /** - * Breadcrumb feed: walk parentId links inside the list store. - * @param id - session id. - * @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk). - */ - ancestry(id: SessionId): SessionSummary[] { - const { byId } = this.list.getSnapshot() - const chain: SessionSummary[] = [] - let cursor: SessionId | undefined = id - while (cursor !== undefined) { - const summary: SessionSummary | undefined = byId[cursor] - if (summary === undefined || chain.includes(summary)) break - chain.unshift(summary) - cursor = summary.parentId - } - return chain - } - - /** - * Lazily mint the scope + binding for an eligible session. Eligibility and - * prune share one predicate (decision 12): listed on the host — a scope is - * born when its session enters the client's view (list mirror row from the - * baseline pull, a create() echo, or the session-added frame) and dies with - * the prune when the row leaves. - */ - private resolve(id: SessionId): ScopeRecord | undefined { - const existing = this.scopes.get(id) - if (existing !== undefined) return existing - if (!this.eligible(id)) return undefined - const { fiber, ctx } = createScope(this.rootCtx, id) - const session = this.manager.get(id) - // The Session owns its scoped dispatch point (host Agent.loopCtx mirror); - // mint and bind are one step so a live scope record implies a bound actx. - session.bindScope(ctx) - const binding: SessionBinding = { sessionId: id, session, ctx } - const record: ScopeRecord = { - fiber, - ctx, - binding, - // Sources are bare observables; React binds selector hooks at its own seam. - provideInfo: this.materializeProvideInfo(binding), - } - this.scopes.set(id, record) - return record - } - - /** The one aliveness predicate shared by scope mint and prune: host-listed. */ - private eligible(id: SessionId): boolean { - return this.list.getSnapshot().byId[id] !== undefined - } - - /** Project the manager's list snapshot into the store (title derivation is display-only). */ - private projectList(): void { - const { items, current, phase } = this.manager.getListSnapshot() - const ids: SessionId[] = [] - const byId: Record = {} - for (const entry of items) { - ids.push(entry.sessionId) - byId[entry.sessionId] = { - id: entry.sessionId, - displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), - running: entry.running, - blank: entry.blank, - updatedAt: entry.updatedAt, - ...(entry.title !== undefined ? { title: entry.title } : {}), - ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), - ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), - } - } - const persisted = this.selection.getSnapshot().sessionId - // No current (cleared, or masked gap) wipes the persisted cell — a reload - // stays on empty; the in-memory selection still resurfaces a masked id. - if (current === undefined) { - if (persisted !== undefined) this.selection.set({}) - } else if (byId[current] !== undefined && persisted !== current) { - this.selection.set({ sessionId: current }) - } - this.list.set({ ids, byId, current, phase }) - this.pruneScopes(byId) - } - - /** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */ - private pruneScopes(byId: Record): void { - void byId - for (const [id, record] of this.scopes) { - if (this.eligible(id)) continue - if (id === this.watched) { - this.deferredRemovals.add(id) - continue - } - this.scopes.delete(id) - this.deferredRemovals.delete(id) - this.dropScope(id, record) - } - } - - /** - * One teardown for the whole per-session axis (decision 12): the scope - * fiber (cascading every actx-registered effect: input shell, slash - * controller, popup, plugin stores, listeners), the session-keyed slot - * stores, and the Session instance itself — the host session log is the - * durable truth, a reopen lazily rebuilds and backfills via open(). - */ - private dropScope(id: SessionId, record: ScopeRecord): void { - void record.fiber.dispose() - // Release the Session's dispatch point with the scope it belongs to (a - // surviving instance — the live Intent — rebinds when resolve re-mints). - record.binding.session.unbindScope() - // Optional lookup: slots and sessions are sibling services with no - // declared dependency; a slots-less boot (object-layer tests) skips. - this.rootCtx.get('slots')?.pruneStoreScope(id) - this.manager.drop(id) - } - - /** Run deferred teardowns whose session is no longer staged (called when the stage moves). */ - private sweepDeferred(): void { - for (const id of [...this.deferredRemovals]) { - /* v8 ignore next -- defensive: only the staged id ever defers, and every - * stage move sweeps first, so the set cannot contain the id the stage just - * moved to; kept as a guard against future extra sweep call sites. */ - if (id === this.watched) continue - // Eligible again? (A re-added id cancels the deferred teardown.) - if (this.eligible(id)) { - this.deferredRemovals.delete(id) - continue - } - const record = this.scopes.get(id) - this.deferredRemovals.delete(id) - /* v8 ignore next -- defensive: prune deletes a scope and its deferral - * together, so a deferred id always still owns its record; kept so a - * future teardown path cannot double-dispose. */ - if (record !== undefined) { - this.scopes.delete(id) - this.dropScope(id, record) - } - } - } -} diff --git a/packages/client/ui-workspace/src/client/index.ts.orig b/packages/client/ui-workspace/src/client/index.ts.orig deleted file mode 100644 index 7b5823cc39..0000000000 --- a/packages/client/ui-workspace/src/client/index.ts.orig +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills - * the sidebar shell's `sidebar.workspaces` hole (the whole browsing region), - * and WorkspacePicker fills the conversation hero's picker hole - * (`conversation.hero.workspace` — both hero forms). Both read real Host - * Workspaces through the global useWorkspaces hook. Export discipline: - * packages/client/AGENTS.md. - */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' -import { createWorkspaceViewStore } from './stores.ts' -import { WorkspaceBrowser } from './WorkspaceBrowser.tsx' -import { WorkspacePicker } from './WorkspacePicker.tsx' - -export type { - WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps, -} from './contract/slots.ts' - -/** - * Required services (cordis fiber inject). The target slots are declared by - * the ui-sidebar / ui-conversation applies, whose activation order relative - * to this one is NOT constrained: dshClient.inject edges are informational - * (loading/prefetch metadata, never apply sequencing) and neither owner - * provides a waitable service. apply therefore registers via - * declaration-aware deferral instead of assuming order. - */ -export const inject = ['slots', 'sessions', 'workspaces'] - -/** - * Register the browser and picker once their slot declarations are on the - * ledger. Inject factories return plain callbacks; data reads use the - * framework's global hooks. - * @param ctx - client root context. - */ -export function apply(ctx: ClientContext): void { - const browserInjected = (): WorkspaceBrowserInjected => ({ - // With a workspace: materialize (reuse-or-create the blank session) and - // navigate. Without one: clear the selection — the layout's empty seat - // shows the New Session pure view state and the user picks there. - startSession: (workspaceId) => { - if (workspaceId === undefined) { - ctx.sessions.clear() - return - } - void ctx.workspaces.connectWorkspace(workspaceId).then( - (sessionId) => { ctx.sessions.open(sessionId) }, - (reason: unknown) => { console.warn('new session failed:', reason) }, - ) - }, - open: (sessionId) => { ctx.sessions.open(sessionId) }, - renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, - insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { - await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) - }, - createWorkspace: input => ctx.workspaces.create(input), - }) - const pickerInjected = (): WorkspacePickerInjected => ({ - createWorkspace: input => ctx.workspaces.create(input), - }) - // Declaration-aware registration: each owner's declaring apply may activate - // after this one (entry activation order is unconstrained), and a register - // into an undeclared slot throws. Register once the declaration is on the - // ledger; the subscription also re-registers after an HMR collapse - // re-declares the slot (the cascade disposed our entry with it). - ctx.effect(() => { - const registrations = [ - { - name: 'sidebar.workspaces' as const, - component: WorkspaceBrowser, - register: () => ctx.slots.register( - { name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected }, - WorkspaceBrowser, - ), - }, - { - name: 'conversation.hero.workspace' as const, - component: WorkspacePicker, - register: () => ctx.slots.register( - { name: 'conversation.hero.workspace', inject: pickerInjected }, - WorkspacePicker, - ), - }, - ] - const disposers = new Map void>() - const tryRegister = (entry: (typeof registrations)[number]): void => { - if (ctx.slots.spec(entry.name) === undefined) return - if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return - disposers.set(entry.name, entry.register()) - } - const unsubscribers = registrations.map(entry => - ctx.slots.subscribe(entry.name, () => { tryRegister(entry) })) - for (const entry of registrations) tryRegister(entry) - return () => { - for (const unsubscribe of unsubscribers) unsubscribe() - for (const dispose of disposers.values()) dispose() - } - }, 'ui-workspace: browser + picker registrations') -} diff --git a/packages/client/ui-workspace/src/client/tree.ts.orig b/packages/client/ui-workspace/src/client/tree.ts.orig deleted file mode 100644 index 6d3126fcd1..0000000000 --- a/packages/client/ui-workspace/src/client/tree.ts.orig +++ /dev/null @@ -1,321 +0,0 @@ -/** - * Derives the workspace browser tree from Host Workspace order and membership. - * Unassigned Sessions trail under Ungrouped; blank Sessions remain visible. - */ -import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' - -/** Group key for Sessions outside every Workspace. */ -export const UNGROUPED_KEY = '' - -/** Display label for the ungrouped bucket row. */ -export const UNGROUPED_LABEL = 'Ungrouped' - -/** One session node of a group's visible tree (34px row; children render indented one step). */ -export interface SessionNode { - id: SessionId - title: string - /** Visible children, already expansion/search-filtered (empty when folded). */ - children: readonly SessionNode[] - /** The session HAS children in the data (the twist renders even while folded). */ - hasChildren: boolean - expanded: boolean - running: boolean - updatedAt: number -} - -/** One workspace group section: header row facts + the visible session tree. */ -export interface GroupNode { - /** Group key: the workspace id or {@link UNGROUPED_KEY}. */ - key: string - /** Backing Workspace id; absent only for the ungrouped bucket. */ - workspaceId: WorkspaceId | undefined - cwd: string | undefined - label: string - /** Total visible sessions in the group. */ - sessionCount: number - expanded: boolean - /** The group contains the selected session (active folder tint; supplied here so the renderer never scans). */ - containsCurrent: boolean - /** Visible roots (empty while the group is folded). */ - sessions: readonly SessionNode[] -} - -/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */ -export interface TreeView { - expandedProjects: readonly string[] - expandedSessions: readonly string[] - query: string -} - -interface Group { - key: string - workspaceId: WorkspaceId | undefined - cwd: string | undefined - label: string - summaries: Map - roots: SessionId[] - children: Map -} - -/** - * Directory display label: basename of the path (both separators accepted). - * Ungrouped-bucket fallback for surfaces without a workspace title. - * @param cwd - directory path, or undefined for the ungrouped bucket. - * @returns basename, the raw cwd when it has no basename, or the ungrouped label. - */ -export function projectLabel(cwd: string | undefined): string { - if (cwd === undefined || cwd === '') return UNGROUPED_LABEL - const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() - return base !== undefined && base !== '' ? base : cwd -} - -/** Recency comparator: newest first, id as the deterministic tiebreak (ids are unique per group). */ -function byRecency(a: SessionSummary, b: SessionSummary): number { - if (b.updatedAt !== a.updatedAt) return b.updatedAt - a.updatedAt - return a.id < b.id ? -1 : 1 -} - -/** Build one group's parent/child tree from an ordered member list. */ -function buildGroup( - key: string, - workspaceId: WorkspaceId | undefined, - cwd: string | undefined, - label: string, - members: readonly SessionSummary[], - order: 'account' | 'recency', -): Group { - const summaries = new Map(members.map(m => [m.id, m])) - const children = new Map() - const roots: SessionSummary[] = [] - for (const m of members) { - // A session is a tree child only when its parent lives in the same - // group; cross-group or unknown parents degrade to group roots. - if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) { - const kids = children.get(m.parentId) - if (kids === undefined) children.set(m.parentId, [m.id]) - else kids.push(m.id) - } else { - roots.push(m) - } - } - // Workspace order is the member iteration order (workspace.sessionIds), so - // attached groups keep insertion order; Ungrouped sorts by recency. - if (order === 'recency') { - roots.sort(byRecency) - for (const kids of children.values()) { - kids.sort((a, b) => { - const sa = summaries.get(a) - const sb = summaries.get(b) - /* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */ - if (sa === undefined || sb === undefined) return 0 - return byRecency(sa, sb) - }) - } - } - const rootIds = roots.map(r => r.id) - // parentId cycles (host bug) leave members unreachable from any root; - // surface them as extra roots — the flatten walk's visited set stops - // loops. Each node sits in at most one kids list and roots have no - // in-group parent, so the scan pushes every reachable node exactly once. - const reachable = new Set(rootIds) - const stack = [...rootIds] - while (stack.length > 0) { - const top = stack.pop() - /* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */ - if (top === undefined) break - for (const kid of children.get(top) ?? []) { - reachable.add(kid) - stack.push(kid) - } - } - for (const m of members) { - if (!reachable.has(m.id)) rootIds.push(m.id) - } - return { key, workspaceId, cwd, label, summaries, roots: rootIds, children } -} - -/** - * Group Sessions by Host Workspace: one group per entity in stable Host - * order, with members resolved from sessionIds in their stored order. Sessions - * outside every Workspace trail in the recency-ordered Ungrouped bucket. - */ -function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] { - const groups: Group[] = [] - const accounted = new Set() - for (const workspace of workspaces) { - const members: SessionSummary[] = [] - for (const id of workspace.sessionIds) { - const summary = list.byId[id] - if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands - accounted.add(id) - members.push(summary) - } - groups.push(buildGroup( - workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.title, members, 'account', - )) - } - const stray = list.ids - .map(id => list.byId[id]) - .filter((s): s is SessionSummary => s !== undefined && !accounted.has(s.id)) - if (stray.length > 0) { - groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, UNGROUPED_LABEL, stray, 'recency')) - } - return groups -} - -function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChildren: boolean, expanded: boolean): SessionNode { - return { - id: s.id, - title: s.displayTitle, - children, - hasChildren, - expanded, - running: s.running, - updatedAt: s.updatedAt, - } -} - -function buildVisible(g: Group, expandedSessions: ReadonlySet): SessionNode[] { - const visited = new Set() - const walk = (id: SessionId): SessionNode | null => { - if (visited.has(id)) return null - visited.add(id) - const s = g.summaries.get(id) - /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */ - if (s === undefined) return null - const kids = g.children.get(id) ?? [] - const expanded = expandedSessions.has(id) - const children = expanded ? kids.map(walk).filter((n): n is SessionNode => n !== null) : [] - return sessionNode(s, children, kids.length > 0, expanded) - } - return g.roots.map(walk).filter((n): n is SessionNode => n !== null) -} - -/** Matched sessions plus their ancestor chains (forced visible under search). */ -function searchVisible(g: Group, q: string): Set { - const visible = new Set() - for (const m of g.summaries.values()) { - if (!m.displayTitle.toLowerCase().includes(q)) continue - let cur: SessionSummary | undefined = m - while (cur !== undefined && !visible.has(cur.id)) { - visible.add(cur.id) - cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined - } - } - return visible -} - -function buildSearch(g: Group, visible: ReadonlySet): SessionNode[] { - const visited = new Set() - const walk = (id: SessionId): SessionNode | null => { - if (visited.has(id) || !visible.has(id)) return null - visited.add(id) - const s = g.summaries.get(id) - /* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */ - if (s === undefined) return null - const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid)) - const children = kids.map(walk).filter((n): n is SessionNode => n !== null) - return sessionNode(s, children, kids.length > 0, kids.length > 0) - } - return g.roots.map(walk).filter((n): n is SessionNode => n !== null) -} - -/** - * Derive the nested workspace browser group structure. - * - * Normal mode: every group shows; sessions populate under expanded groups, - * descending only into expanded sessions. Search mode (non-blank query, - * case-insensitive display-title substring): expansion state is ignored — - * matched sessions and their ancestor chains are forced visible, groups - * without a display-title or label hit are dropped, and a label-only hit - * keeps the bare group header. Blank sessions are excluded everywhere. - * @param list - sessions list snapshot (`current` feeds containsCurrent). - * @param workspaces - real workspaces in stable Host order. - * @param view - local expansion arrays and search query. - * @returns group sections in render order. - */ -export function deriveGroups( - list: SessionListState, - workspaces: readonly WorkspaceView[], - view: TreeView, -): GroupNode[] { - const q = view.query.trim().toLowerCase() - const expandedProjects = new Set(view.expandedProjects) - const expandedSessions = new Set(view.expandedSessions) - const currentGroup = list.current === undefined - ? undefined - : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined) - ?? UNGROUPED_KEY - const groups: GroupNode[] = [] - for (const g of groupByWorkspace(list, workspaces)) { - if (q === '') { - const expanded = expandedProjects.has(g.key) - groups.push({ - key: g.key, - workspaceId: g.workspaceId, - cwd: g.cwd, - label: g.label, - sessionCount: g.summaries.size, - expanded, - containsCurrent: g.key === currentGroup, - sessions: expanded ? buildVisible(g, expandedSessions) : [], - }) - } else { - const visible = searchVisible(g, q) - if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue - groups.push({ - key: g.key, - workspaceId: g.workspaceId, - cwd: g.cwd, - label: g.label, - sessionCount: g.summaries.size, - expanded: visible.size > 0, - containsCurrent: g.key === currentGroup, - sessions: buildSearch(g, visible), - }) - } - } - return groups -} - -/** - * Derive the flat session list ("In one list" mode): every session — fork - * children included — as a top-level row, strictly newest-first. No grouping, - * no parent/child adjacency; rows reuse SessionNode with children always - * empty so the renderer stays branch-free. Search mode filters by - * case-insensitive display-title substring. - * @param list - sessions list snapshot. - * @param view - the search query (expansion state does not apply). - * @returns flat rows in render order. - */ -export function deriveFlat(list: SessionListState, view: Pick): SessionNode[] { - const q = view.query.trim().toLowerCase() - const rows: SessionSummary[] = [] - for (const id of list.ids) { - const s = list.byId[id] - if (s === undefined) continue - if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue - rows.push(s) - } - rows.sort(byRecency) - return rows.map(s => sessionNode(s, [], false, false)) -} - -/** - * Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y"). - * @param updatedAt - epoch ms of the session's last activity. - * @param now - current epoch ms (injected for pure rendering). - * @returns the row's trailing time label. - */ -export function formatRelativeTime(updatedAt: number, now: number): string { - const MIN = 60_000 - const HOUR = 3_600_000 - const DAY = 86_400_000 - const diff = Math.max(0, now - updatedAt) - if (diff < MIN) return 'now' - if (diff < HOUR) return `${Math.floor(diff / MIN)}min` - if (diff < DAY) return `${Math.floor(diff / HOUR)}h` - if (diff < 30 * DAY) return `${Math.floor(diff / DAY)}d` - if (diff < 365 * DAY) return `${Math.floor(diff / (30 * DAY))}mo` - return `${Math.floor(diff / (365 * DAY))}y` -} From 5cae7874f61c9af8fcc8df4f585ec5259766a61e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:26:58 +0800 Subject: [PATCH 51/70] git: add .orig to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c488fa5a91..4651b94215 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ python/**/.pytest_cache/ apps/web/dist/ .artifacts/ .playwright-mcp/ +.orig \ No newline at end of file From ac1431e4f5c79010284bea88579a5afd952f8ff9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 27 Jul 2026 17:47:48 +0800 Subject: [PATCH 52/70] fix(session-title): improve prompt bad case: when i start the session with a skill reference, llm returns XML --- packages/session-title/session-title-llm/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/session-title/session-title-llm/src/index.ts b/packages/session-title/session-title-llm/src/index.ts index 672292bbf6..673b50311a 100644 --- a/packages/session-title/session-title-llm/src/index.ts +++ b/packages/session-title/session-title-llm/src/index.ts @@ -191,7 +191,7 @@ function resolveRoute( function systemPrompt(config: ResolvedSessionTitleLlmConfig): string { return [ 'Create a concise title for an AI coding-assistant session from the supplied human messages.', - 'Return only the title on one line, with no quotes, prefix, explanation, Markdown, or terminal control codes.', + 'Return only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.', 'Use the language of the messages.', `Aim for about ${config.targetWords} words in non-CJK languages or ${config.targetCjkCharacters} CJK characters.`, ].join('\n') From d270c285ab7d349521f75865c9b9804de68c0d8a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:49:11 +0800 Subject: [PATCH 53/70] fix(review): portable checksum verifier and full provisioning wait sha256sum-or-shasum selection happens in preflight (macOS ships shasum, not GNU coreutils) with a clear missing-tool hint; provisioning now waits for every child and reports each named status before exiting, so a fast failure can neither hand the EXIT trap a scratch tree that a still-running install is writing into nor bury the root cause behind a downstream symptom. --- scripts/wine-windows-gates.sh | 44 +++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 97f09bb9b3..8b6dcd8a0a 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -33,10 +33,19 @@ wine_bin='' for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do if [ -n "$candidate" ] && [ -x "$candidate" ]; then wine_bin="$candidate"; break; fi done +# GNU coreutils sha256sum on Linux; perl shasum ships with macOS. Both +# accept the same " " --check input. +checksum_tool='' +if command -v sha256sum > /dev/null; then + checksum_tool='sha256sum' +elif command -v shasum > /dev/null; then + checksum_tool='shasum' +fi missing=() [ -n "$wine_bin" ] || missing+=('wine (apt: wine | brew: wine-stable)') command -v curl > /dev/null || missing+=('curl') command -v unzip > /dev/null || missing+=('unzip') +[ -n "$checksum_tool" ] || missing+=('sha256sum or shasum (apt: coreutils | macOS ships shasum)') if ! command -v pnpm > /dev/null; then corepack enable > /dev/null 2>&1 || true; fi command -v pnpm > /dev/null || missing+=('pnpm (corepack enable)') if (( ${#missing[@]} > 0 )); then @@ -44,6 +53,14 @@ if (( ${#missing[@]} > 0 )); then exit 1 fi +# Verify file $2 against SHA-256 hex $1 with whichever tool preflight found. +verify_sha256() { + case "$checksum_tool" in + sha256sum) printf '%s %s\n' "$1" "$2" | sha256sum --check - > /dev/null ;; + shasum) printf '%s %s\n' "$1" "$2" | shasum -a 256 --check - > /dev/null ;; + esac +} + scratch="$(mktemp -d "${TMPDIR:-/tmp}/dsh-wine-gates.XXXXXX")" cleanup() { wineserver -k > /dev/null 2>&1 || true @@ -68,9 +85,11 @@ provision_node() { zip="$cache_dir/node-$version-win-x64.zip" if [ ! -f "$zip" ]; then curl -fsSL -o "$zip.tmp" "https://nodejs.org/dist/$version/node-$version-win-x64.zip" - curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \ - | awk -v a="node-$version-win-x64.zip" -v f="$zip.tmp" '$2 == a { print $1 " " f }' \ - | sha256sum --check - > /dev/null + local expected + expected="$(curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \ + | awk -v a="node-$version-win-x64.zip" '$2 == a { print $1; exit }')" + [ -n "$expected" ] || { echo "wine-windows-gates: no SHASUMS256 entry for node-$version-win-x64.zip" >&2; exit 1; } + verify_sha256 "$expected" "$zip.tmp" mv "$zip.tmp" "$zip" fi else @@ -115,7 +134,24 @@ start=$SECONDS provision_node & node_pid=$! boot_wine & wine_pid=$! snapshot_and_install & install_pid=$! -for task_pid in "$node_pid" "$wine_pid" "$install_pid"; do wait "$task_pid"; done +# Wait for EVERY child before judging any: a bare `wait` under set -e would +# exit on the first failure and let the EXIT trap delete $scratch while the +# other children still run inside it. Named statuses also make the report +# point at the root cause instead of a downstream symptom. +node_status=0; wait "$node_pid" || node_status=$? +wine_status=0; wait "$wine_pid" || wine_status=$? +install_status=0; wait "$install_pid" || install_status=$? +provision_failed=0 +report_provision() { + if (( $2 != 0 )); then + echo "wine-windows-gates: FAILED $1 (exit $2)" >&2 + provision_failed=$2 + fi +} +report_provision 'Windows Node provisioning' "$node_status" +report_provision 'wineboot' "$wine_status" +report_provision 'workspace snapshot + pnpm install' "$install_status" +if (( provision_failed != 0 )); then exit "$provision_failed"; fi node_win="$(cat "$scratch/node-win-path")" echo "wine-windows-gates: provisioned in $((SECONDS - start))s (wine $("$wine_bin" --version 2> /dev/null), node $(basename "$(dirname "$node_win")"))" From 194ea6bdf0d8a9cdec2e94de9f1f992b44f6f7d0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:03:21 +0800 Subject: [PATCH 54/70] docs(test): correct execa process-tree claims --- .../2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml | 6 +++--- .../2026-07-26-execa-for-test-subprocess-plumbing.md | 4 ++-- .../2026-07-26-execa-for-test-subprocess-plumbing.zh.md | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml index 606229b494..466071f552 100644 --- a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml @@ -1,6 +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 -2026-07-26-execa-for-test-subprocess-plumbing.md: a25010b1cab7012cf9c659cfd8272d17e33618c5 -2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 733c9f7e7f666052f030ed3b0f916e4832aaa120 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md +2026-07-26-execa-for-test-subprocess-plumbing.md: 958abc4aee94adb3e6206cc299595ad92bde4044 +2026-07-26-execa-for-test-subprocess-plumbing.zh.md: 7027a8bde51f81bfa7774743f84639cbd4b667d8 diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md index a25010b1ca..958abc4aee 100644 --- a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.md @@ -25,13 +25,13 @@ Two related test-infra hand-rolls compounded the case: ## Alternatives considered - **`tinyexec` instead of execa.** Already in `node_modules` transitively via vitest, smaller API — but no kill-escalation, no rich error output embedding, and being transitive is not a contract; if the lighter package is preferred the swap shape is identical. -- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn Windows behavior (taskkill, exit codes) that execa already carries. +- **A repo-local shared spawn helper (no new dep).** Viable and cheaper on supply chain, but it keeps the maintenance of deadline/kill/settlement logic in-repo when a battle-tested package owns exactly this; contrary to the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md), it also has to re-earn cross-platform timeout, termination, and result-normalization behavior that execa already carries. - **`get-port`, `wait-on`, `tempy`, `tree-kill`.** Rejected individually: the repo's single port probe is break-even, the file waits are dominated by `vi.waitFor`, temp-dir handling already uses `mkdtemp` + `rm {recursive}` builtins everywhere, and acp-snapshot's `close()` is drain-ordering logic, not tree traversal. ## Consequences - The hand-rolled collect/timeout blocks are gone, including the two `/* v8 ignore */` un-inducible OS-error branches in `loader-smoke`: spawn and stream failures settle through execa's result fields, so the `src/` file carries no coverage exemptions and the per-file gate covers every remaining branch. - Captured output is bounded by execa's default 100 MB `maxBuffer` (overflow terminates the subprocess) where it was previously unbounded; the `loader-smoke` README's limitation entry reflects this. -- Windows termination behavior (taskkill, exit-code mapping) is owned by execa instead of per-site hand-rolls; each rewritten suite was re-run on POSIX in this change, and the Windows CI lanes own the other platform. +- Direct-child timeout termination and exit/signal result normalization are owned by execa across platforms instead of per-site hand-rolls; process-tree termination remains outside these helpers, as the `loader-smoke` README states. Each rewritten suite was re-run on POSIX in this change, and the Windows CI lanes own the other platform. - execa is a new root devDependency (previously absent from the lockfile); it is one of the most-depended-on packages on npm and actively maintained, and the exe/runtime closure is unaffected (tests only). - The mock-server CLI's tokenizer-level error texts are no longer this repo's to choose: unknown options, missing values, and stray positionals report `parseArgs`'s wording, pinned as such in `tests/cli.spec.ts`. diff --git a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md index 733c9f7e7f..7027a8bde5 100644 --- a/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md +++ b/.agents/notes/implemented/testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md @@ -25,13 +25,13 @@ Status: implemented ## 曾考虑的替代方案 - **用 `tinyexec` 代替 execa。**它已经作为 vitest 的传递依赖存在于 `node_modules` 中,API 也更小;但它没有终止信号逐级升级,不会把丰富的输出嵌入错误对象,而且传递依赖并不构成契约。如果最终更倾向这个更轻的包,替换的形态完全相同。 -- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的 Windows 行为(taskkill、退出码)。 +- **仓库内共享的 spawn 辅助函数(不引入新依赖)。**可行,供应链成本也更低,但当一个久经实战的包恰好负责这件事时,它把截止时限、终止与结算逻辑的维护留在了仓库内;这与[依赖策略](../process/2026-07-26-dependencies-over-hand-rolling.md)背道而驰,它还得重新踩坑换来 execa 已经自带的跨平台超时、终止与结果规范化行为。 - **`get-port`、`wait-on`、`tempy`、`tree-kill`。**逐一不予采纳:仓库仅有的一处端口探测替换后收支相抵;文件等待场景已由 `vi.waitFor` 更优地覆盖;临时目录处理在各处已经使用内置的 `mkdtemp` + `rm {recursive}`;acp-snapshot 的 `close()` 是排空顺序逻辑,不是进程树遍历。 ## 后果 - 手写的收集/超时代码块全部移除,包括 `loader-smoke` 中两个标注 `/* v8 ignore */`、无法人为诱发的 OS 错误分支:spawn 与流故障如今经由 execa 的结果字段结算,这个 `src/` 文件不再携带任何覆盖率豁免,逐文件门禁覆盖其余全部分支。 - 捕获的输出如今受 execa 默认 100 MB `maxBuffer` 约束(溢出即终止子进程),此前是无界的;`loader-smoke` README 的局限条目反映了这一点。 -- Windows 终止行为(taskkill、退出码映射)由 execa 拥有,不再逐处手写;每个改写后的套件在本次变更中已在 POSIX 上重新运行,另一平台由 Windows CI 车道负责。 +- 直接子进程的超时终止以及退出/信号结果规范化均由 execa 跨平台负责,不再逐处手写;如 `loader-smoke` README 所述,这些辅助函数依然不负责终止进程树。每个改写后的套件在本次变更中已在 POSIX 上重新运行,另一平台由 Windows CI 车道负责。 - execa 是新增的根 devDependency(此前完全不存在于 lockfile 中);它是 npm 上被依赖最多的包之一且维护活跃,exe/运行时闭包不受影响(仅测试使用)。 - mock-server CLI 切分器层面的错误文本不再由本仓库决定:未知选项、缺失取值与多余位置参数报告 `parseArgs` 的措辞,并在 `tests/cli.spec.ts` 中如此固定。 From 45c7fe4ebe2afd1a37edef6802b062245c43492c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 18:29:52 +0800 Subject: [PATCH 55/70] docs(pty): re-record the note pair after the master merge --- .../feature/2026-07-16-persistent-pty-sessions.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 4c73d590af..00815d6a5d 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -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/feature/2026-07-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: 43c87bb159cfe1ab9f8d3a80c2adf25a57ae6e3b -2026-07-16-persistent-pty-sessions.zh.md: 8afc2103447cc58b1fcbc1062b9564e8ed643477 +2026-07-16-persistent-pty-sessions.md: 598aaeb034339bec956b4566aa08362c7cbba723 +2026-07-16-persistent-pty-sessions.zh.md: 4f207ee9c8d04a577019a565384f4b662636968b From 6adb14b56d0d550358f42b9073429af11aafba7e Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 27 Jul 2026 18:35:03 +0800 Subject: [PATCH 56/70] =?UTF-8?q?feat(telemetry):=20adopt=20from=20the=20c?= =?UTF-8?q?onstruction=20boundary=20=E2=80=94=20constructor=20seeds=20neve?= =?UTF-8?q?r=20re-export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cursor-less adoption (process restart + resume, fork, seam-module reload) replayed the session's full log from seq 0, re-exporting history that already left the process — a resume re-billed its entire stored log on every restart, and a fork re-shipped the parent's prefix under the child's id, doubling query-time counts on OTLP backends with no native ingest dedupe. dsh-session now exposes the fact the constructor already validated but discarded: Session.firstLiveSeq, the constructor-seed length — the first seq appended in this process. header.seedLength cannot serve here: it is the durable fork-lineage boundary, and a resumed session's constructor seed is its full stored log while the header keeps the original fork value (llm-replay and session-query-sqlite depend on that meaning). Constructor seeds also never publish on the session/event firehose, so adoption replaying them was inconsistent with the system's own publication semantics. Adoption's cursor-less fallback starts at firstLiveSeq; seed events still feed the chunk projection, so mid-step continuations re-drop after a resume. Fork streams are no longer self-contained: records now carry session.seed_length (with the existing session.parent_id) so receivers stitch the child's stream onto the parent's. Accepted cost, consistent with at-most-once delivery and recorded in the revival Agent Note: a resume no longer backfills records a previous process failed to deliver — a deployment with that requirement needs the deferred outbox, not replay. Pinned red-first: seeded adoption exports nothing (assertion reversed from the prior seed-readback test, obsolete behavior changed with its test), resume-shaped seed rebuilds the projection without exporting, and fork records carry the stitch attributes. --- ...3-session-telemetry-otel-revival.i18n.yaml | 4 +- ...26-07-23-session-telemetry-otel-revival.md | 2 + ...07-23-session-telemetry-otel-revival.zh.md | 2 + docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.md | 12 ++++ docs/core-data-structures/session.zh.md | 12 ++++ .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/session/src/index.ts | 14 ++++ .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session-telemetry-otel/README.md | 2 +- .../session-telemetry-otel/README.zh.md | 2 +- .../session-telemetry/README.i18n.yaml | 4 +- .../telemetry/session-telemetry/README.md | 6 +- .../telemetry/session-telemetry/README.zh.md | 6 +- .../session-telemetry/src/coordinator.ts | 23 +++++-- .../session-telemetry/tests/telemetry.spec.ts | 64 +++++++++++++++++-- 17 files changed, 134 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index e17d42d2bb..9accad167a 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -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 -2026-07-23-session-telemetry-otel-revival.md: 476073751a52989434798d4efa0013fc9908d6fa -2026-07-23-session-telemetry-otel-revival.zh.md: 78372554bc57ef298d399b7f01ba8a122964d5d5 +2026-07-23-session-telemetry-otel-revival.md: f790bafe07caa6da00621f395de62909be9ee238 +2026-07-23-session-telemetry-otel-revival.zh.md: 9b2cf657541cc08ba238c5b5d952f8908e825537 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index 476073751a..f790bafe07 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -28,6 +28,8 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry **Map onto OTel spans (GenAI semantic conventions) instead of logs.** Rejected for this revival: the branch implementation's log mapping is reviewed and shipped-shaped; the span model is lossy for forkable, interruptible sessions and belongs to a future consumer with real span queries to serve. +**Full-log replay when no handoff cursor survived (re-export constructor seeds).** Shipped in the first revival round, then narrowed: adoption now replays from the session's construction boundary (`Session.firstLiveSeq`, the constructor-seed length — a fact the session already validated but did not expose; `header.seedLength` cannot serve, it is the durable fork-lineage value and a resumed session's constructor seed is its full stored log). A resumed session's history already shipped from the previous process under the same id, and a fork's inherited prefix already shipped in the parent's stream — re-exporting either re-billed every resume for its full history and doubled query-time counts on OTLP backends with no native ingest dedupe. Receivers stitch fork lineage via `session.parent_id` + `session.seed_length`. What the narrowing gives up, consistently with the at-most-once stance: a resume no longer backfills records the previous process failed to deliver (telemetry unmounted then, or queued at crash) — the full replay's only real benefit, bought at the common case's expense. A deployment that states a backfill requirement needs the deferred outbox above, not replay. + **Forwarding the seam's turn-boundary `flush()` hint to the OTel provider's `forceFlush()`.** Shipped in the first revival round, then removed after three review rounds each found a new silent-loss path in the same wrapper state: a dispose racing an in-flight flush (the SDK's concurrent-flush guard makes shutdown's internal drain skip), overlapping hints displacing the retained promise, and the provider's fixed 30-second flush timeout rejecting while the processor still drains. Every path exists only because the forwarding made this backend the process's second flusher against undocumented SDK internals from the upstream experimental tree; with no `flush()` implemented, the batch processor is the only flusher, its `scheduledDelayMillis` (already deployment-tunable through the `processor` passthrough) governs export cadence, and `shutdown()`'s drain is complete by construction. Reinstate only if a deployment states a turn-boundary latency requirement `scheduledDelayMillis` cannot meet — and then by calling the retained `BatchLogRecordProcessor`'s own `forceFlush()`, never the provider's timeout-wrapped one. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index 78372554bc..9b2cf65754 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -28,6 +28,8 @@ Status: implemented **映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 +**handoff 游标未存活时全量回放日志(重新导出构造函数种子)。** 首轮复活曾交付此方案,其后收窄:收养现在从会话的构造边界起回放(`Session.firstLiveSeq`,即构造函数种子长度,这一事实会话早已校验过却未曾暴露;`header.seedLength` 不能胜任:它是持久保存的 fork 谱系(lineage)值,而恢复会话的构造函数种子是其完整的已存储日志)。恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀也已在父会话的流中发出;再次导出任何一者,都会让每次恢复为其完整历史重复付费,并在没有原生摄取去重的 OTLP 后端上使查询时的计数翻倍。接收端基于 `session.parent_id` + `session.seed_length` 拼接 fork 谱系。此次收窄放弃的内容与至多一次立场一致:恢复不再回填上一个进程未能投递的记录(彼时遥测未挂载,或崩溃时仍在队列中)——这本是全量回放唯一的真实收益,代价却由常见情形承担。提出回填要求的部署需要的是上文已推迟的 outbox,而不是回放。 + **将 seam 的轮次边界 `flush()` 提示转发到 OTel provider 的 `forceFlush()`。** 首轮复活曾交付此转发,其后移除:三轮评审在同一份包装层状态中各发现一条新的静默丢失路径——dispose 与进行中的 flush 之间的竞态(SDK 的并发 flush 防护会令 shutdown 的内部排空被跳过)、相互重叠的提示顶掉留存的 promise、以及 provider 固定的 30 秒 flush 超时在批处理器仍在排空时便 reject。这些路径存在的唯一原因,是该转发让这个后端成为进程内第二个执行 flush 的组件,面对的还是上游实验性(experimental)源码树中未见诸文档的 SDK 内部行为;不实现 `flush()` 时,批处理器就是唯一执行 flush 的组件,其 `scheduledDelayMillis`(已可由部署方经 `processor` passthrough 调优)决定导出节奏,`shutdown()` 的排空从构造上就是完整的。仅当某个部署提出 `scheduledDelayMillis` 无法满足的轮次边界延迟要求时才恢复此转发——且届时应调用留存的 `BatchLogRecordProcessor` 自身的 `forceFlush()`,绝不调用 provider 那个带超时包装的版本。 ## Consequences diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d255d1a70a..eb8bca7c15 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1342,7 +1342,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:606`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:620`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 454bd17c38..773a436b94 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -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 -session.md: d789ffcabb5cb0c744e265b61e322831c1d8a04f -session.zh.md: f4f102861db7403520e9f38cb56613e430718cbe +session.md: ae388f8de3a8b963a662cbe8d8736ab42ad378f9 +session.zh.md: cac769fc5c02952eefd5a01845dbb44d60e99ece diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index d789ffcabb..ae388f8de3 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -370,6 +370,18 @@ declare class Session { readonly header: SessionHeader; /** The session identity, derived from its durable header's single copy. */ get id(): SessionId; + /** + * The first seq appended IN THIS PROCESS: the length of the constructor + * seed (0 without one). Events below it entered through construction — + * replay, fork, or resume — and were never published on the `session/event` + * firehose (constructor seeds do not emit), so consumers that replay the + * log as a publication substitute (telemetry adoption) start here. Distinct + * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed + * session's constructor seed is its full stored log, while its header keeps + * the original fork value — this field is the in-process construction fact + * and is deliberately not persisted. + */ + readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); /** * An immutable snapshot of the append-only event log. The snapshot is reused diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index f4f102861d..cac769fc5c 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -372,6 +372,18 @@ declare class Session { readonly header: SessionHeader; /** The session identity, derived from its durable header's single copy. */ get id(): SessionId; + /** + * The first seq appended IN THIS PROCESS: the length of the constructor + * seed (0 without one). Events below it entered through construction — + * replay, fork, or resume — and were never published on the `session/event` + * firehose (constructor seeds do not emit), so consumers that replay the + * log as a publication substitute (telemetry adoption) start here. Distinct + * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed + * session's constructor seed is its full stored log, while its header keeps + * the original fork value — this field is the in-process construction fact + * and is deliberately not persisted. + */ + readonly firstLiveSeq: number; constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); /** * An immutable snapshot of the append-only event log. The snapshot is reused diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index e1dad3ed89..b88404a94b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1935,7 +1935,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Session', - declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', + declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', }, { name: 'SessionAvailability', diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 5a6b274b3e..f502bbd92a 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -300,6 +300,19 @@ export class Session { return this.header.id } + /** + * The first seq appended IN THIS PROCESS: the length of the constructor + * seed (0 without one). Events below it entered through construction — + * replay, fork, or resume — and were never published on the `session/event` + * firehose (constructor seeds do not emit), so consumers that replay the + * log as a publication substitute (telemetry adoption) start here. Distinct + * from `header.seedLength`, the DURABLE fork-lineage boundary: a resumed + * session's constructor seed is its full stored log, while its header keeps + * the original fork value — this field is the in-process construction fact + * and is deliberately not persisted. + */ + readonly firstLiveSeq: number + constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) { if (seed) { // Validate the seed to the SAME invariants `append` enforces, so a @@ -332,6 +345,7 @@ export class Session { this.log.push(deepFreeze(snapshot)) } } + this.firstLiveSeq = this.log.length this.header = snapshotSessionHeader(id, header) } diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index 89ba658a6f..c53fa68694 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/README.i18n.yaml @@ -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 -README.md: 28fffc5f43f960de1a44700aa70050d792b81d4f -README.zh.md: 821969fea09487d9d4450e981a0d791a66dad3a1 +README.md: 8dd2c4f6535d6d3ea772f7669185f62c4694ca43 +README.zh.md: 42bc9b7ddbbf6d9cb104de35d9489e6e583e1df7 diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 28fffc5f43..8dd2c4f653 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -25,7 +25,7 @@ Records carry the complete `event.data` as the seam's `telemetry/redact` waterfa ## Field mapping -Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record absence (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). The marker means telemetry stopped observing the session cleanly — emitted at the session's own disposal, or at application teardown for sessions still running then; a marker followed by more of that session's events is a telemetry reload, not a session restart. +Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record absence (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). The marker means telemetry stopped observing the session cleanly — emitted at the session's own disposal, or at application teardown for sessions still running then; a marker followed by more of that session's events is a telemetry reload, not a session restart. Streams are not self-contained across lineage: a resumed session continues its own id's stream from where the previous process left off, and a forked session's stream starts at its inherited boundary — its prefix lives in the parent's stream, stitched via `session.parent_id` + `session.seed_length`. ## Model Experience diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 821969fea0..42bc9b7ddb 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -25,7 +25,7 @@ ## 字段映射 -seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`;`severity` → `severityNumber`/`severityText`(INFO 9 / WARN 13 / ERROR 17);`body` → 结构化日志 body;`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重、按严重级别告警,并通过 `shutdown` 记录的缺失检测崩溃(一个曾有活动、没有 `shutdown` 运维记录、且已然陈旧的会话,就是未干净结束的会话)。该标记的含义是遥测干净地停止了对该会话的观察:它在会话自身 dispose(资源释放)时发出,对于届时仍在运行的会话,则在应用拆卸时发出;标记之后又出现该会话的更多事件,说明发生的是遥测重载,而不是会话重启。 +seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`;`severity` → `severityNumber`/`severityText`(INFO 9 / WARN 13 / ERROR 17);`body` → 结构化日志 body;`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重、按严重级别告警,并通过 `shutdown` 记录的缺失检测崩溃(一个曾有活动、没有 `shutdown` 运维记录、且已然陈旧的会话,就是未干净结束的会话)。该标记的含义是遥测干净地停止了对该会话的观察:它在会话自身 dispose(资源释放)时发出,对于届时仍在运行的会话,则在应用拆卸时发出;标记之后又出现该会话的更多事件,说明发生的是遥测重载,而不是会话重启。跨谱系(lineage)的流并不自足:恢复的会话在其自身 id 的流上从上一个进程停止之处继续;fork 出的会话,其流从继承边界开始,前缀位于父会话的流中,由接收端基于 `session.parent_id` + `session.seed_length` 拼接。 ## 模型体验 diff --git a/packages/telemetry/session-telemetry/README.i18n.yaml b/packages/telemetry/session-telemetry/README.i18n.yaml index dd0da745e8..6c905a5ea1 100644 --- a/packages/telemetry/session-telemetry/README.i18n.yaml +++ b/packages/telemetry/session-telemetry/README.i18n.yaml @@ -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 -README.md: df0384d0528f0b3a95ded414444e1b11ea7d52bf -README.zh.md: d86fede206a4b2363f1deda86ff32d868d1db71d +README.md: 76f9b242ca0b4779a9ab10e5ae93bab1ffb5e581 +README.zh.md: d930dc92e41e85c597aaeff840e6e6b03712b06d diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index df0384d052..76f9b242ca 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -10,7 +10,7 @@ The telemetry seam: the CAPTURE side of session-event reporting, behind a backen ## Capture points -The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection — seed events from fork/resume never re-emit on the firehose), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (mark each session still alive at teardown, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). +The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (emit the session's `shutdown` operational record at its own termination edge — where receivers key crash detection — then retire it, so a long-lived backend neither retains closed sessions nor re-marks them at unload), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (mark each session still alive at teardown, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). ## The redact waterfall @@ -18,7 +18,7 @@ Every record passes the `telemetry/redact` waterfall between projection and `emi ## The handoff cursor -A module-scope `WeakMap` marks the highest seq HANDED OFF (not delivered) per session, advanced at emit time. It survives reloads that do not re-evaluate this module — config re-applies and backend source reloads, which is where iteration happens; that asymmetry is why the cursor lives in the seam. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a full re-hand, absorbed by receiver-side dedupe on `(session.id, event.seq)`. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error. +A module-scope `WeakMap` marks the highest seq HANDED OFF (not delivered) per session, advanced at emit time. It survives reloads that do not re-evaluate this module — config re-applies and backend source reloads, which is where iteration happens; that asymmetry is why the cursor lives in the seam. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a re-hand from the session's construction boundary (`Session.firstLiveSeq` — seq 0 for a session born in this process), absorbed by receiver-side dedupe on `(session.id, event.seq)`. Constructor seeds never re-export: a resumed session's history shipped from the previous process under the same id, and a fork's inherited prefix lives in the parent's stream (receivers stitch on `session.parent_id` + `session.seed_length`). The accepted cost, consistent with at-most-once delivery: a resume does not backfill records a previous process failed to deliver — a deployment with a backfill requirement needs the deferred outbox, not replay. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error. ## The fixed chunk projection @@ -26,7 +26,7 @@ Only the first `assistant/chunk` of each `(turn, step)` ships; the rest are drop ## The logical record -`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError` and `turn/end` error reasons; WARN for `prompt/blocked`; INFO otherwise, including plugin-merged event types whose outcome semantics stay with their owners), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `telemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`. +`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError` and `turn/end` error reasons; WARN for `prompt/blocked`; INFO otherwise, including plugin-merged event types whose outcome semantics stay with their owners), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id`/`session.seed_length` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `telemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`. ## Model Experience diff --git a/packages/telemetry/session-telemetry/README.zh.md b/packages/telemetry/session-telemetry/README.zh.md index d86fede206..d930dc92e4 100644 --- a/packages/telemetry/session-telemetry/README.zh.md +++ b/packages/telemetry/session-telemetry/README.zh.md @@ -10,7 +10,7 @@ ## 捕获点 -协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影回读日志;来自 fork 或恢复的种子事件绝不会在 firehose 上再次发出)、`session/event`(投影、深拷贝、脱敏、交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘发出该会话的 `shutdown` 运维记录,接收端正是在这个边缘锚定崩溃检测;随后将该会话退役,因此长生命周期的后端既不会保留已关闭的会话,也不会在卸载时再次标记它们)、`agent/error`(唯一的实时总线转发;轮次封闭机制在结构上决定了这些错误进不了日志)、一个 dispose effect(拆卸时先标记每个仍存活的会话,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。 +协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏、交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘发出该会话的 `shutdown` 运维记录,接收端正是在这个边缘锚定崩溃检测;随后将该会话退役,因此长生命周期的后端既不会保留已关闭的会话,也不会在卸载时再次标记它们)、`agent/error`(唯一的实时总线转发;轮次封闭机制在结构上决定了这些错误进不了日志)、一个 dispose effect(拆卸时先标记每个仍存活的会话,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。 ## 脱敏 waterfall @@ -18,7 +18,7 @@ ## handoff 游标 -一个模块作用域的 `WeakMap` 记录每个会话已交接(而非已投递)的最高 seq,在 emit 时推进。游标在不重新求值本模块的重载(配置重新应用、后端源码重载)中存活,而迭代恰恰发生在这类重载中;这种不对称正是游标放在 seam 一侧的原因。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为全量重新交接,由接收端基于 `(session.id, event.seq)` 的去重吸收。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。 +一个模块作用域的 `WeakMap` 记录每个会话已交接(而非已投递)的最高 seq,在 emit 时推进。游标在不重新求值本模块的重载(配置重新应用、后端源码重载)中存活,而迭代恰恰发生在这类重载中;这种不对称正是游标放在 seam 一侧的原因。重新收养时,协调器只重新交接游标之后的事件(游标及其之前的事件仍用于重建分片投影状态);游标缺失时安全退化为从会话构造边界起的重新交接(`Session.firstLiveSeq`,对在本进程中诞生的会话即 seq 0),由接收端基于 `(session.id, event.seq)` 的去重吸收。构造函数种子绝不会再次导出:恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀则位于父会话的流中(接收端基于 `session.parent_id` + `session.seed_length` 拼接)。由此接受的代价与至多一次(at-most-once)投递一致:恢复不会回填上一个进程未能投递的记录;有回填要求的部署需要的是已推迟的 outbox,而不是回放。这是对「注册即 effect」纪律的一次有意且范围极窄的例外:条目随其会话消亡,值是单调水位线,丢失它绝不是错误。 ## 固定分片投影 @@ -26,7 +26,7 @@ ## 逻辑记录 -`TelemetryRecord` 包含:`channel`(`ledger` | `ops`)、`time`(epoch 毫秒)、`severity`(预先映射好的严重级别:`tool/result.isError` 与 `turn/end` 的错误原因映射为 ERROR,`prompt/blocked` 映射为 WARN,其余为 INFO,包括结果语义仍归其所有者的插件合并事件类型)、只含身份信息的 `attributes`(`session.id`、`event.type`、`event.seq`,header 中存在时再加 `session.cwd`/`session.parent_id`),以及作为 `body` 的完整深拷贝 `event.data`,且以脱敏后的内容为准。运维记录携带 `telemetry.op`(`agent-error` | `shutdown`)和 `session.id`,并刻意不带 `event.seq`/`event.type`:它们是用来告警的信号,不是用来累加的条目。交接之后的投递由后端 SDK 负责;重复仍然可能出现(无游标的重新收养、SDK 重试),因此接收端基于 `(session.id, event.seq)` 去重。 +`TelemetryRecord` 包含:`channel`(`ledger` | `ops`)、`time`(epoch 毫秒)、`severity`(预先映射好的严重级别:`tool/result.isError` 与 `turn/end` 的错误原因映射为 ERROR,`prompt/blocked` 映射为 WARN,其余为 INFO,包括结果语义仍归其所有者的插件合并事件类型)、只含身份信息的 `attributes`(`session.id`、`event.type`、`event.seq`,header 中存在时再加 `session.cwd`/`session.parent_id`/`session.seed_length`),以及作为 `body` 的完整深拷贝 `event.data`,且以脱敏后的内容为准。运维记录携带 `telemetry.op`(`agent-error` | `shutdown`)和 `session.id`,并刻意不带 `event.seq`/`event.type`:它们是用来告警的信号,不是用来累加的条目。交接之后的投递由后端 SDK 负责;重复仍然可能出现(无游标的重新收养、SDK 重试),因此接收端基于 `(session.id, event.seq)` 去重。 ## 模型体验 diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index d500a66d89..a5b552c765 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -117,17 +117,23 @@ export class TelemetryCoordinator { /** * Adopt a session: replay its log THROUGH the projection from the handoff - * cursor (or from the start when no cursor survived), then rely on the - * firehose for everything after. Events at or below the cursor still feed - * the projection state (first-chunk tracking) without being re-handed, so - * a resumed fiber drops mid-step chunk continuations exactly like the - * fiber that saw the step begin. + * cursor, then rely on the firehose for everything after. When no cursor + * survived, replay starts at the session's construction boundary + * (`firstLiveSeq`), not seq 0: constructor seeds never publish on the + * firehose, and their content already left the process under another + * identity — the same id in a previous process (resume) or the parent's + * stream (fork, stitched by receivers via `session.seed_length`). Events + * at or below the start still feed the projection state (first-chunk + * tracking) without being re-handed, so a resumed fiber drops mid-step + * chunk continuations exactly like the fiber that saw the step begin. The + * cost, accepted with the seam's at-most-once stance: a resume no longer + * backfills records a previous process failed to deliver. * @param session - the live session to adopt; a second adoption is a no-op. */ private adopt(session: Session): void { if (this.adopted.has(session)) return this.adopted.add(session) - const cursor = handoffCursor.get(session) ?? -1 + const cursor = handoffCursor.get(session) ?? session.firstLiveSeq - 1 // Containment is PER EVENT, matching the firehose: one rejected record // is withheld fail-closed while the rest of the historical replay // proceeds — wrapping the whole loop would let a single failure silently @@ -264,8 +270,11 @@ function identityOf(session: Session, event: SessionEvent): Record { }) describe('TelemetryCoordinator adoption', () => { - it('reads seeded events back at adoption (fork/resume seeds never re-emit)', async () => { + it('starts export at the construction boundary: seeded history never re-exports', async () => { const backend = new FakeBackend() const ctx = new Context() await ctx.plugin(SessionStore) const parent = liveSession(ctx, 'seed-parent') appendTurn(parent) - ctx.sessions.create(SessionId('seeded'), { seed: [...parent.events], meta: {} }) + const child = ctx.sessions.create(SessionId('seeded'), { seed: [...parent.events], meta: {} }) await ctx.plugin({ name: 'fake-telemetry', inject: ['sessions'], apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), }) + // The live parent (no constructor seed) replays in full; the child's + // inherited prefix already left the process under another identity (the + // parent's id here; the same id in a previous process for a resume) and + // must not be re-exported — only its live suffix ships. const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']]) - expect(seqs).toEqual(expect.arrayContaining([ - ['seed-parent', 0], ['seed-parent', 1], - ['seeded', 0], ['seeded', 1], - ])) + expect(seqs).toEqual(expect.arrayContaining([['seed-parent', 0], ['seed-parent', 1]])) + expect(seqs.filter(([id]) => id === 'seeded')).toEqual([]) + child.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']])) + .toEqual(expect.arrayContaining([['seeded', 2]])) + }) + + it('resume shape: a full-log seed exports nothing yet still rebuilds the chunk projection', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const donor = ctx.sessions.create(SessionId('donor'), { meta: {} }) + donor.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + donor.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }) + const resumed = ctx.sessions.create(SessionId('resumed'), { seed: [...donor.events], meta: {} }) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + const ofResumed = () => backend.ledger() + .filter(r => r.attributes['session.id'] === 'resumed') + .map(r => r.attributes['event.seq']) + expect(ofResumed()).toEqual([]) + // The seed fed the projection: the (turn 1, step 1) first chunk already + // shipped from the original process, so its continuation is re-dropped… + resumed.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'continuation' } }) + expect(ofResumed()).toEqual([]) + // …while a new step's first chunk exports normally. + resumed.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'next step' } }) + expect(ofResumed()).toEqual([3]) + }) + + it('stamps session.seed_length from the header so receivers can stitch fork streams', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const parent = liveSession(ctx, 'stitch-parent') + appendTurn(parent) + const child = ctx.sessions.create(SessionId('stitch-child'), { + seed: [...parent.events], + meta: { parentSession: SessionId('stitch-parent'), seedLength: 2 }, + }) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + child.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const record = backend.ledger().find(r => r.attributes['session.id'] === 'stitch-child')! + expect(record.attributes['session.parent_id']).toBe('stitch-parent') + expect(record.attributes['session.seed_length']).toBe(2) }) it('adopts exactly once when created fires after the sweep', async () => { From 1665ab9884bee46c144c6aa5fbd9e115e497e08a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:39:20 +0800 Subject: [PATCH 57/70] ci: preserve stores across pnpm action setup Keep restore-only and self-hosted stores outside pnpm/action-setup's replaceable install directory, resolving the configured path for every paired cache. Pin Yarn 4 for the generated-project e2e so runner images without Corepack do not fall back to Yarn Classic, and update the bilingual decision record. --- ...n-setup-for-symmetric-ci-caching.i18n.yaml | 6 +- ...m-action-setup-for-symmetric-ci-caching.md | 12 ++- ...ction-setup-for-symmetric-ci-caching.zh.md | 12 ++- .github/workflows/ci.yml | 99 +++++++++++++------ knip.json | 1 + package.json | 3 +- pnpm-lock.yaml | 10 ++ 7 files changed, 99 insertions(+), 44 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml index 0b452fcf81..7ad4d7bee7 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml @@ -1,6 +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 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: d734f6be65affb15f7e56c08b0b6d268002a44d8 -2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 77ea0aa1cc7443ca7cd9e89b652a9c908bab64e5 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 006360cedfd4d1e2c2b67ede98062a375e316f47 +2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: d511e7a2b89788fe8addd2cc47634742c7a87fe4 diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md index d734f6be65..006360cedf 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md @@ -6,15 +6,15 @@ English | [中文](2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md) ## Problem -Every workflow hand-provisioned pnpm with `corepack enable`, and five of them further repeated a hand-rolled cache setup — `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml` (~40–60 YAML lines of drifting copies). The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — was already proven in-repo in `landlock-run.yml`, and corepack's removal from newer Node distributions made every `corepack enable` a known future break. +Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned it with `corepack enable`, and five of them further repeated a hand-rolled cache setup — `pnpm store path --silent >> $GITHUB_OUTPUT`, then `actions/cache@v4` keyed on `pnpm-lock.yaml`: `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat, serial-linux, and benchmark jobs of `ci.yml`. The maintained equivalent — `pnpm/action-setup@v4` (reads `packageManager` from package.json) plus `actions/setup-node` with `cache: pnpm` — was already proven in-repo in `landlock-run.yml`, and corepack's removal from newer Node distributions made every `corepack enable` a known future break. ## Decision -`pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. Caching remains per-job policy on top of it, in three deliberate shapes: +`pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. The root dev dependency on `@yarnpkg/cli-dist` separately supplies the modern Yarn CLI exercised by the generated-project e2e; package-manager coverage therefore does not inherit the runner image's Yarn Classic. Caching remains per-job policy on top of pnpm provisioning, in three deliberate shapes: - **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms. -- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps, unchanged): the three enterprise-runner PR jobs restore without saving, keeping cache compression/upload off the paid latency-critical path — an asymmetry `setup-node`'s cache cannot express — and the master-push serial-linux job keeps its `pnpm store path` + `actions/cache@v4` save side, because it populates the exact key and path those restore-only jobs consume; converting the producer to `setup-node`'s key format would silently starve their restores. -- **Cache-less** (no store cache at all): the required Windows job and serial-windows (many-file store extraction is slower than a clean install there), serial-macos, sandbox.yml, and the coverage/consumers enterprise jobs that already restore via the shared enterprise key. +- **Restore-only / producer pairing** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based pull-request Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path, matching the master-push serial-linux producer's path and exact key; the enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm. +- **Cache-less or persistent** (no store-cache action): native serial-windows and serial-macos plus `sandbox.yml` install from a cold or runner-local store. The self-hosted standby and failover jobs reuse their VM's persistent pnpm store without transferring a hosted cache archive. ## Alternatives considered @@ -22,11 +22,13 @@ Every workflow hand-provisioned pnpm with `corepack enable`, and five of them fu - **Convert the enterprise jobs' caching to `cache: pnpm`.** Rejected: the restore-only asymmetry is a documented latency decision in `ci.yml`'s comments; erasing it to unify tooling inverts the priority. - **Convert serial-linux's store cache.** Rejected during implementation: the original proposal counted serial-linux among the symmetric setups, but its cache step is the producer half of the enterprise jobs' restore-only pairing — moving it to `setup-node`'s key format is the enterprise conversion by another route. - **Stop at the cache-bearing workflows and leave the other `corepack enable` sites.** Rejected on review follow-up: provisioning and caching are separable concerns, and leaving corepack in the cache-less jobs kept the future break and two provisioning idioms for no benefit. +- **Rely on the runner image's Yarn.** Rejected: the hosted image exposes Yarn 1.22 after Corepack is removed, while the generated-project e2e requires Yarn 2 or newer. A locked root dev dependency makes that coverage independent of runner image contents. - **A composite action wrapping action-setup + setup-node.** Rejected for now: the remaining per-job variation (node-version matrices, per-platform conditional caching, the restore-only pairing) is deliberate policy, not boilerplate — a wrapper would grow mirroring inputs or flatten a real asymmetry, and the two-line pair is already near the floor. ## Consequences - The corepack dependency is gone from CI entirely; pnpm arrives via the pnpm team's official action everywhere, and the version pin stays single-sourced in `package.json`'s `packageManager` field. +- The generated-project e2e runs the root-pinned Yarn 4 CLI instead of inheriting or silently skipping the runner image's Yarn version. - The cache-key format changed once for converted lanes; one cold run repopulated it, after which hit rates match the old steps. The built-in key spans platform, arch, and the lockfile hash but not the Node version, so the node-compat matrix legs share one store entry — safe, because the pnpm store is Node-version-independent. - `setup-node`'s built-in pnpm cache restores by exact key only, with no `restore-keys` prefix fallback: a `pnpm-lock.yaml` change starts a converted lane from a cold store instead of seeding from the previous entry. -- About 75 net lines of workflow YAML removed. The enterprise-runner PR jobs' and Windows jobs' cache behavior is unchanged (only their provisioning line moved to the action), and serial-linux keeps producing the key the restore-only jobs consume. +- `pnpm/action-setup` deletes its install directory on every run and places the default store beneath the resulting `PNPM_HOME`. Linux jobs that need cache pairing or self-hosted persistence therefore set `PNPM_CONFIG_STORE_DIR` to `$HOME/.local/share/pnpm/store`, outside the action directory; the restore-only jobs and serial-linux resolve and share that stable path and exact key. diff --git a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md index 77ea0aa1cc..d511e7a2b8 100644 --- a/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -每个工作流都曾用 `corepack enable` 手工提供 pnpm,其中五个还各自重复着一套手写(hand-rolled)的缓存设置——`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业(约 40–60 行各自漂移的 YAML 副本)。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——当时已在仓库内的 `landlock-run.yml` 中得到验证,而 corepack 被从较新 Node 发行版中移除,使每一处 `corepack enable` 都成了已知的未来失效点。 +除 `landlock-run.yml` 外,每个安装 pnpm 的工作流都曾用 `corepack enable` 手工提供 pnpm,其中五个还各自重复着一套手写(hand-rolled)的缓存设置——`pnpm store path --silent >> $GITHUB_OUTPUT`、再加以 `pnpm-lock.yaml` 为缓存键的 `actions/cache@v4`:`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat、serial-linux 与 benchmark 作业。与之等价、由官方维护的做法——`pnpm/action-setup@v4`(从 package.json 读取 `packageManager`)加带 `cache: pnpm` 的 `actions/setup-node`——当时已在仓库内的 `landlock-run.yml` 中得到验证,而 corepack 被从较新 Node 发行版中移除,使每一处 `corepack enable` 都成了已知的未来失效点。 ## 决策 -`pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。缓存仍是叠加其上的按作业政策,保持三种刻意的形态: +`pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI(命令行界面);因此,用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业政策,保持三种刻意的形态: - **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。 -- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤,保持不变):企业 runner 上的三个 PR(Pull Request)作业只恢复不保存,把缓存压缩/上传挡在付费且延迟敏感的关键路径之外——这种不对称是 `setup-node` 的缓存无法表达的;master 推送触发的 serial-linux 作业保留其 `pnpm store path` + `actions/cache@v4` 写入侧,因为它负责填充那些只恢复不上传(restore-only)作业所消费的精确缓存键与路径;把生产者改成 `setup-node` 的键格式,会悄然断供它们的恢复。 -- **无缓存**(完全不设 store 缓存):必需的 Windows 作业与 serial-windows(在那里解压海量小文件的 store 缓存比干净安装更慢)、serial-macos、sandbox.yml,以及本就经共享企业键恢复的 coverage/consumers 企业作业。 +- **只恢复不上传/生产者配对**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业与基于 Wine 的拉取请求 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径,从而与 master 推送触发的 serial-linux 生产者所用的路径和精确键匹配;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已能直接提供热安装。 +- **无缓存或持久化**(不使用 store 缓存 action):原生 serial-windows 和 serial-macos 加上 `sandbox.yml` 从冷 store 或 runner 本地 store 安装。自托管热备与故障切换作业复用其 VM 的持久 pnpm store,不传输托管缓存归档。 ## 曾考虑的替代方案 @@ -22,11 +22,13 @@ Status: implemented - **把企业作业的缓存也转换成 `cache: pnpm`。** 否决:只恢复不上传的不对称是 `ci.yml` 注释中有记录的延迟决策;为统一工具而抹掉它,属于颠倒优先级。 - **转换 serial-linux 的 store 缓存。** 实现期间否决:原提案曾把 serial-linux 计入对称设置,但其缓存步骤是企业作业只恢复不上传配对中的生产者一半——把它改成 `setup-node` 的键格式,等于换条路径做了企业作业的转换。 - **只转换带缓存的工作流,留下其余 `corepack enable` 站点。** 评审跟进时否决:提供 pnpm 与缓存是可分离的关注点,在无缓存作业里留下 corepack 只会保留未来失效点和两套并存的提供方式,毫无收益。 +- **依赖 runner 镜像自带的 Yarn。** 否决:Corepack 移除后,托管镜像提供的是 Yarn 1.22,而 generated-project e2e 要求 Yarn 2 或更高版本。锁定版本的根开发依赖让该项覆盖率不再受 runner 镜像内容影响。 - **用一个组合 action 包装 action-setup + setup-node。** 暂不采纳:剩余的按作业差异(node 版本矩阵、按平台的条件缓存、只恢复不上传配对)是刻意的政策而非样板——包装层要么长出镜像这些差异的输入,要么抹平一处真实的不对称,而两行的组合已接近下限。 ## 后果 - corepack 依赖已从 CI 中彻底消失;pnpm 在所有工作流中都经由 pnpm 团队的官方 action 提供,版本锁定继续单一来源于 `package.json` 的 `packageManager` 字段。 +- generated-project e2e 运行根目录锁定的 Yarn 4 CLI,既不再沿用 runner 镜像中的 Yarn 版本,也不会因此悄然跳过。 - 已转换泳道的缓存键格式变更了一次;各跑一次冷运行重建缓存后,命中率与旧步骤持平。内建缓存键涵盖平台、架构与锁文件哈希,但不含 Node 版本,因此 node-compat 矩阵的各条腿共享同一条 store 缓存记录——这是安全的,因为 pnpm store 与 Node 版本无关。 - `setup-node` 内建的 pnpm 缓存只按精确键恢复,没有 `restore-keys` 前缀回退:`pnpm-lock.yaml` 一旦变更,已转换泳道会从冷 store 起步,而不是从上一条缓存记录播种。 -- 净删除约 75 行工作流 YAML。企业 runner 上的 PR 作业与 Windows 作业的缓存行为未变(只有提供 pnpm 的那一行改用了 action),serial-linux 继续生产只恢复不上传作业所消费的缓存键。 +- `pnpm/action-setup` 每次运行都会删除其安装目录,并把默认 store 放在由此产生的 `PNPM_HOME` 下。因此,需要缓存配对或自托管持久化的 Linux 作业会把 `PNPM_CONFIG_STORE_DIR` 设为 `$HOME/.local/share/pnpm/store`,置于 action 目录之外;只恢复不上传的作业与 serial-linux 会解析并共享这一稳定路径及精确键。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 349f4ef88c..615aaf8fc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,23 +58,31 @@ jobs: fetch-depth: 0 persist-credentials: false + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + # Pull requests consume the default-branch cache but do not put cache # compression and upload on the paid latency-critical path. Skipped # under failover — see the coverage lane's identical rationale. - uses: actions/cache/restore@v4 if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' with: - path: /home/runner/.local/share/pnpm/store/v11 + path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: pnpm/action-setup@v4 - - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Install (immutable) run: pnpm install --frozen-lockfile @@ -117,23 +125,31 @@ jobs: with: persist-credentials: false - # Skipped under failover: the self-hosted VM's persistent pnpm store - # serves warm installs directly, and this hosted-path restore would - # spend ~52 s pulling ~180 MB into a path pnpm never reads there. - - uses: actions/cache/restore@v4 - if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' - with: - path: /home/runner/.local/share/pnpm/store/v11 - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + # Skipped under failover: the self-hosted VM's persistent pnpm store + # already serves warm installs, while restoring the hosted archive + # would spend ~52 s pulling ~180 MB into that populated store. + - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - name: Install dependencies and prepare bubblewrap run: | pnpm install --frozen-lockfile & @@ -180,15 +196,6 @@ jobs: - name: Restore built tree run: tar -xzf "$RUNNER_TEMP/node-24-built-tree.tar.gz" - # Skipped under failover — see the coverage lane's identical rationale. - - uses: actions/cache/restore@v4 - if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' - with: - path: /home/runner/.local/share/pnpm/store/v11 - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - - uses: actions/cache/restore@v4 with: path: .cache/eslint @@ -202,6 +209,23 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + # Skipped under failover — see the coverage lane's identical rationale. + - uses: actions/cache/restore@v4 + if: vars.DSH_CI_FAILOVER != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + - name: Install dependencies and prepare bubblewrap run: | pnpm install --frozen-lockfile & @@ -340,9 +364,17 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + - uses: actions/cache/restore@v4 with: - path: /home/runner/.local/share/pnpm/store/v11 + path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- @@ -548,9 +580,13 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Resolve pnpm store path + - name: Configure pnpm store path id: pnpm-store - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" # Master refreshes the caches that pull requests restore without saving. # The store cache stays a hand-rolled actions/cache step rather than @@ -621,6 +657,9 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + - name: Configure persistent pnpm store + run: echo "PNPM_CONFIG_STORE_DIR=$HOME/.local/share/pnpm/store" >> "$GITHUB_ENV" + - name: Install (immutable) run: pnpm install --frozen-lockfile diff --git a/knip.json b/knip.json index 110abb3a2b..7ab7d47e5f 100644 --- a/knip.json +++ b/knip.json @@ -13,6 +13,7 @@ "python/sdk-runtime" ], "ignoreDependencies": [ + "@yarnpkg/cli-dist", "lightningcss" ], "workspaces": { diff --git a/package.json b/package.json index 3aa3d6998a..27f192466c 100644 --- a/package.json +++ b/package.json @@ -113,9 +113,10 @@ "@types/mdast": "^4.0.4", "@types/node": "^22.20.0", "@vitest/coverage-v8": "^4.1.8", + "@yarnpkg/cli-dist": "4.17.1", "eslint": "^10.4.1", - "execa": "^10.0.0", "eslint-plugin-sonarjs": "^4.1.0", + "execa": "^10.0.0", "fast-check": "^4.8.0", "js-yaml": "^4.2.0", "jscpd": "^5.0.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f82de24032..a24a701f61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.8 version: 4.1.8(vitest@4.1.8) + '@yarnpkg/cli-dist': + specifier: 4.17.1 + version: 4.17.1 eslint: specifier: ^10.4.1 version: 10.5.0(jiti@2.7.0) @@ -7468,6 +7471,11 @@ packages: '@xterm/headless@5.5.0': resolution: {integrity: sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==} + '@yarnpkg/cli-dist@4.17.1': + resolution: {integrity: sha512-2tiSQuJNl/L3QwTdrq6lKWDpkcnp9MGvCT/rIldHcbu3SWfnLdmehvt3eulX1hT7FFt1Gjfq3CesF+kvhFip6g==} + engines: {node: '>=18.12.0'} + hasBin: true + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -12364,6 +12372,8 @@ snapshots: '@xterm/headless@5.5.0': {} + '@yarnpkg/cli-dist@4.17.1': {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 From 7b9eceff5378c64bd1c7db63af7fd34d5856de49 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:43:00 +0800 Subject: [PATCH 58/70] fix(ci): make local Wine gate portable on macOS --- ...27-wine-windows-gates-experiment.i18n.yaml | 4 +-- ...026-07-27-wine-windows-gates-experiment.md | 2 +- ...-07-27-wine-windows-gates-experiment.zh.md | 2 +- scripts/client-tsconfig.spec.ts | 33 +++++++++++++++++++ tsconfig.client.json | 3 ++ 5 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 scripts/client-tsconfig.spec.ts diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml index 5f939a7b67..b8d12ccea3 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml @@ -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/process/2026-07-27-wine-windows-gates-experiment.md -2026-07-27-wine-windows-gates-experiment.md: fd36a3322b0d63a0fa605ecfcec95c7522b43b25 -2026-07-27-wine-windows-gates-experiment.zh.md: 97a54161bb851785a9108cc45d59617e1a449e68 +2026-07-27-wine-windows-gates-experiment.md: 640c8e455b1a35ea4ac83454227147b9979316dc +2026-07-27-wine-windows-gates-experiment.zh.md: f30e09ca7411ef83d02faf012ce54d6c6c65dff1 diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md index fd36a3322b..640c8e455b 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md @@ -20,7 +20,7 @@ The lane holds the wall clock of the Linux CI jobs through four levers: the mast The gate logic lives in one script, [scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh): the ci.yml job provisions runner state (caches, apt Wine) and calls it, and the optional local gate `pnpm run check:windows-wine` runs the identical script on a developer machine that has Wine installed — one implementation, so local reproduction of a red CI lane needs no translation between environments. The local gate is a diagnosis tool, not a routine check: run it only when investigating a known Windows-related failure; CI owns the everyday win32 signal, and [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) never selects it. The script never mutates the working tree: it snapshots tracked plus untracked-unignored files into a scratch directory, applies the Wine-specific pnpm overrides to the snapshot only, and installs there against the shared store; the Wine prefix and the checksum-verified Windows Node zip persist under `.cache/wine-windows/` so local reruns skip provisioning, with an offline fallback to the newest cached zip when nodejs.org is unreachable. -Four environment constraints shape the job, each found as a red run: Ubuntu's `wine64` package alone puts nothing on PATH (install `wine`, the dispatcher); Node under Wine cannot attach stdio to the Actions runner's pipes (`Socket open EBADF` at bootstrap — every invocation routes stdio through a file); Wine does not realpath pnpm's isolated-layout Unix symlinks (the hoisted layout above); and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate). +Five environment constraints shape CI and local execution, each found as a red run: Ubuntu's `wine64` package alone puts nothing on PATH (install `wine`, the dispatcher); Node under Wine cannot attach stdio to the caller's pipes (`Socket open EBADF` at bootstrap — every invocation routes stdio through a file); Wine does not realpath pnpm's isolated-layout Unix symlinks (the hoisted layout above); macOS Wine also exposes hoisted workspace links as ordinary directories, so the client test aggregate includes every package-local CSS module declaration instead of relying on project-reference realpaths; and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate). ## Measured results diff --git a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md index 97a54161bb..f30e09ca74 100644 --- a/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md @@ -20,7 +20,7 @@ Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表 门禁逻辑集中在一个脚本里,[scripts/wine-windows-gates.sh](../../../../scripts/wine-windows-gates.sh):ci.yml 作业只供给 runner 状态(缓存、apt Wine)然后调用它,可选的本地门禁 `pnpm run check:windows-wine` 在装有 Wine 的开发机上运行同一个脚本——单一实现,因此本地复现红色 CI 通道不需要在环境之间做任何转译。该本地门禁是诊断工具而非例行检查:仅在排查已知的 Windows 相关失败时运行;日常 win32 信号归 CI 所有,[dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 也从不选择它。脚本从不改动工作树:把被跟踪加未跟踪未忽略的文件快照进一个临时目录,只对快照施加 Wine 特有的 pnpm 覆盖,并在那里对着共享 store 安装;Wine prefix 与校验和验证过的 Windows Node zip 持久存放在 `.cache/wine-windows/` 下,本地重跑跳过供给,nodejs.org 不可达时回退到最新的已缓存 zip。 -四条环境约束塑造了该作业,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到 Actions runner 的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。 +五条环境约束塑造了 CI 与本地执行,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到调用方的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);macOS Wine 也会把 hoisted workspace 链接暴露为普通目录,因此 client 测试聚合会纳入每个包自己的 CSS 模块声明,而不依赖 project-reference realpath;Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。 ## 实测结果 diff --git a/scripts/client-tsconfig.spec.ts b/scripts/client-tsconfig.spec.ts new file mode 100644 index 0000000000..ed445aa80f --- /dev/null +++ b/scripts/client-tsconfig.spec.ts @@ -0,0 +1,33 @@ +/** Regression coverage for source declarations owned by the client test aggregate. */ + +import { existsSync, readdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +const root = fileURLToPath(new URL('..', import.meta.url)) + +function clientCssDeclarations(): string[] { + const clientRoot = resolve(root, 'packages/client') + return readdirSync(clientRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => resolve(clientRoot, entry.name, 'src/css-modules.d.ts')) + .filter(existsSync) + .sort() +} + +describe('client TypeScript aggregate', () => { + it('loads package CSS declarations without relying on workspace-link realpaths', () => { + const configPath = resolve(root, 'tsconfig.client.json') + const read = ts.readConfigFile(configPath, file => ts.sys.readFile(file)) + if (read.error !== undefined) { + throw new Error(ts.flattenDiagnosticMessageText(read.error.messageText, '\n')) + } + const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, root) + const loaded = parsed.fileNames + .filter(file => file.endsWith('/src/css-modules.d.ts')) + .sort() + expect(loaded).toEqual(clientCssDeclarations()) + }) +}) diff --git a/tsconfig.client.json b/tsconfig.client.json index 0da6e76918..d309b78261 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -14,6 +14,9 @@ "types": ["node"] }, "include": [ + // Source-subpath test imports can arrive through workspace links whose + // realpath semantics vary by host. Load package CSS declarations directly. + "packages/client/*/src/css-modules.d.ts", "packages/client/*/tests/**/*.ts", "packages/client/*/tests/**/*.tsx", "packages/client/tsdown.client.ts", From 153157195cf8bc5364b544c5f2c5457f8c09e454 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 18:47:23 +0800 Subject: [PATCH 59/70] docs(pty): scope the settle boundary to the settled operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paragraph claimed later child output reaches only the scrollback. Output after a settle still reaches the scrollback and whichever send is active when it arrives — appendOutput writes both (session.ts:302-307), and settleActive clears `active` rather than suppressing delivery. Only the settled operation stops receiving it, which is what the timing bound protects. --- .../feature/2026-07-16-persistent-pty-sessions.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-16-persistent-pty-sessions.md | 2 +- .../feature/2026-07-16-persistent-pty-sessions.zh.md | 2 +- packages/pty/pty-local/tests/local.spec.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 00815d6a5d..34e6c14df6 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -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/feature/2026-07-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: 598aaeb034339bec956b4566aa08362c7cbba723 -2026-07-16-persistent-pty-sessions.zh.md: 4f207ee9c8d04a577019a565384f4b662636968b +2026-07-16-persistent-pty-sessions.md: 4fff1742721fa13ea11f1b8ec833e5e5b7e68df8 +2026-07-16-persistent-pty-sessions.zh.md: 88e7a0bc4b8af21dc51b6a654a1c9f93760b3e81 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 598aaeb034..4fff174272 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -80,7 +80,7 @@ On macOS there is no exact syscall tier. Output silence returns `inferred_idle` Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.tasks`, signal the foreground group, or inspect from another session. -Once a send settles under any tier, `PtySendOperation.append` stops accepting output, so later child output reaches only the scrollback. A test that waits for a marker on the operation must therefore set `idleSilenceMs` and `timeoutMs` above the child's own startup latency; interpreter startup on a loaded macOS runner otherwise ends the send before the marker is printed. +Once a send settles under any tier, `PtySendOperation.append` stops accepting output, so later child output no longer reaches that settled operation; it still reaches the scrollback, and any send that is active when it arrives. A test that waits for a marker on the operation it started must therefore set `idleSilenceMs` and `timeoutMs` above the child's own startup latency; interpreter startup on a loaded macOS runner otherwise ends the send before the marker is printed. `node-pty` data notifications feed one terminal parser. Parser carry state handles control sequences and a trailing carriage return split across callbacks, so a divided CRLF produces one newline rather than a pagination-changing blank line. The implementation normalizes line-oriented output, but it does not promise correct interaction with a full-screen application. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 4f207ee9c8..88e7a0bc4b 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -80,7 +80,7 @@ macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 ` Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。 -一次 send 在任一层级 settle 之后,`PtySendOperation.append` 就不再接受输出,此后子进程的输出只会进入 scrollback。因此,在 operation 上等待标记的测试必须把 `idleSilenceMs` 与 `timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。 +一次 send 在任一层级 settle 之后,`PtySendOperation.append` 就不再接受输出,此后子进程的输出不会再进入那个已 settle 的 operation;它仍然会进入 scrollback,以及此时恰好处于活跃状态的任何 send。因此,等待自己所启动的 operation 上出现标记的测试,必须把 `idleSilenceMs` 与 `timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。 `node-pty` data 通知进入同一个终端 parser。parser 的 carry state 会处理跨 callback 的控制序列和位于 callback 末尾的回车;因此,即使 CRLF 被拆开,也只会生成一个换行,而不会产生改变分页的空行。实现会规范化行式输出,但不承诺正确操作全屏应用。 diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 82795b5518..f3ed6675e7 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -67,9 +67,9 @@ async function harness( } // PtySendOperation.append drops output once the operation settles, so this only -// observes a marker the child prints while the send is still active. A caller +// observes a marker the child prints while `operation` is still active. A caller // whose child is slow to print must raise the harness `timing` bounds too; -// extending this deadline alone cannot recover output the send never collected. +// extending this deadline alone cannot recover output the operation never collected. async function waitForOutput(operation: PtySendOperation, expected: string, timeoutMs = 2_000): Promise { const deadline = Date.now() + timeoutMs let output = '' From 961bde64737c7030c7a8351260e7cc589f342f37 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:48:11 +0800 Subject: [PATCH 60/70] test(sdk): keep fresh Yarn installs mutable in CI The live-link E2E intentionally creates a project without a lockfile. Prevent ambient CI=true from making Yarn 4's first install immutable, while preserving the CI environment for every other behavior under test. --- packages/sdk/create-sdk/tests/link-workspace.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/sdk/create-sdk/tests/link-workspace.e2e.ts b/packages/sdk/create-sdk/tests/link-workspace.e2e.ts index 0fefefdffa..7d1944614d 100644 --- a/packages/sdk/create-sdk/tests/link-workspace.e2e.ts +++ b/packages/sdk/create-sdk/tests/link-workspace.e2e.ts @@ -90,6 +90,8 @@ describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', () XDG_DATA_HOME: join(cacheRoot, 'data'), npm_config_cache: join(cacheRoot, 'npm'), ...pnpmStore === undefined ? {} : { pnpm_config_store_dir: pnpmStore }, + // A generated project has no lockfile yet; ambient CI must not make its first Yarn install immutable. + ...name === 'yarn' ? { YARN_ENABLE_IMMUTABLE_INSTALLS: 'false' } : {}, } await execFileAsync(name, manager.installCommand(), { cwd: root, From fef928a7e1799048d9452ade918617a88272954e Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:49:49 -0700 Subject: [PATCH 61/70] feat(gui): add native workspace folder picker --- ...ative-workspace-directory-picker.i18n.yaml | 6 + ...07-27-native-workspace-directory-picker.md | 51 +++++++ ...27-native-workspace-directory-picker.zh.md | 51 +++++++ apps/web/tests/slash-flow.snapshot.ts | 1 - apps/web/tests/support.ts | 1 - apps/web/tests/workspace-flow.snapshot.ts | 3 +- apps/web/tests/workspace-management.e2e.ts | 44 +++--- .../client/connection/src/client/fixture.ts | 2 + packages/client/connection/src/index.ts | 12 +- .../connection/src/native-dialog-request.ts | 52 +++++++ packages/client/connection/tests/fake-api.ts | 3 + .../connection/tests/http-bridge.spec.ts | 47 ++++++ .../tests/native-dialog-request.spec.ts | 40 ++++++ .../client/connection/tests/node-half.spec.ts | 18 +++ packages/client/runtime/src/client/index.ts | 2 +- .../runtime/src/client/workspaces/service.ts | 24 +++- packages/client/runtime/tests/fake-api.ts | 3 + .../runtime/tests/workspaces-service.spec.ts | 26 +++- .../ui-conversation/src/client/apply.ts | 23 ++- .../src/client/contract/slots.ts | 2 +- .../src/client/skeleton/ConversationRoot.tsx | 28 +++- .../ui-conversation/tests/skeleton.spec.tsx | 38 ++++- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 6 +- packages/client/ui-workspace/README.zh.md | 6 +- .../src/client/WorkspaceBrowser.tsx | 2 + .../src/client/WorkspacePicker.tsx | 101 +++++++------ .../ui-workspace/src/client/contract/slots.ts | 4 + .../client/ui-workspace/src/client/index.ts | 2 + .../client/ui-workspace/tests/apply.spec.ts | 9 +- .../tests/workspace-browser.spec.tsx | 1 + .../tests/workspace-picker.spec.tsx | 88 +++++++++--- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 3 + packages/host/apiproxy/README.zh.md | 3 + packages/host/apiproxy/src/api-proxy.ts | 23 +++ packages/host/apiproxy/src/api/host.schema.ts | 8 ++ packages/host/apiproxy/src/api/host.ts | 6 + packages/host/apiproxy/src/api/rpc-map.ts | 1 + packages/host/apiproxy/src/fetch/client.ts | 25 +++- packages/host/apiproxy/src/fetch/handler.ts | 3 +- .../apiproxy/src/native-directory-picker.ts | 136 ++++++++++++++++++ .../tests/api-proxy-workspace.spec.ts | 45 ++++++ .../apiproxy/tests/client-handler.spec.ts | 6 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 41 +++++- .../tests/native-directory-picker.spec.ts | 58 ++++++++ 46 files changed, 910 insertions(+), 152 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md create mode 100644 packages/client/connection/src/native-dialog-request.ts create mode 100644 packages/client/connection/tests/http-bridge.spec.ts create mode 100644 packages/client/connection/tests/native-dialog-request.spec.ts create mode 100644 packages/host/apiproxy/src/native-directory-picker.ts create mode 100644 packages/host/apiproxy/tests/native-directory-picker.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml new file mode 100644 index 0000000000..47416d5a30 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml @@ -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/feature/2026-07-27-native-workspace-directory-picker.md +2026-07-27-native-workspace-directory-picker.md: 847b7a7c7844a47e534183008acfdc32e2e08125 +2026-07-27-native-workspace-directory-picker.zh.md: e484a2ba5860d43925aba82be31c803716a33bc5 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md new file mode 100644 index 0000000000..847b7a7c78 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md @@ -0,0 +1,51 @@ +# Agent Note: Native workspace directory picker + +Status: implemented + +English | [中文](2026-07-27-native-workspace-directory-picker.zh.md) + +## Problem + +The desktop GUI asks users to type an absolute path when they add an existing workspace. This is slower and more error-prone than choosing a directory with the operating system's native picker. The GUI is delivered through the local Web carrier, so opening a native dialog also creates a privileged boundary that ordinary remote requests must not cross. + +## Decision + +Add a single-folder `host.pickDirectory` RPC and expose it through `WorkspacesService`. The workspace menu presents two flat actions: **Open local folder...** and **Create a new workspace**. Selecting a folder reuses the existing `workspace.create({ path })` flow, selects the returned workspace, and starts a blank session. + +The workspace manager must upsert the returned workspace before the selection callback runs. A newly adopted directory therefore renders its basename immediately. Reopening an already registered path preserves its existing workspace title. + +## Interaction contract + +- The picker accepts one directory on macOS, Windows, and Linux. +- Cancelling the system dialog is silent and returns `null`. +- A duplicate path selects the existing workspace. +- A different path whose derived title conflicts with another workspace shows a focused error with **Choose again** and **Cancel** actions. +- Other picker failures show a compact retryable error. +- The existing create-by-name flow remains unchanged. + +## Host boundary + +The native dialog RPC is accepted only from a loopback socket with same-origin browser metadata. The RPC does not use the default 30-second request timeout because a system dialog may remain open indefinitely; caller and connection aborts still propagate to the platform process. + +Platform adapters invoke native tools without a shell: + +- macOS: `osascript` and the system folder chooser. +- Windows: PowerShell in STA mode and `FolderBrowserDialog`. +- Linux: `zenity`, with `kdialog` as a fallback when Zenity is unavailable. + +## Alternatives considered + +- A custom directory browser duplicates operating-system behavior and permissions, and belongs to the Web implementation rather than this desktop-only change. +- Reusing the manual path field keeps the current error-prone interaction. +- Adding authentication infrastructure for one local native dialog would expand the change beyond its threat model; loopback and same-origin checks are sufficient for this carrier. + +## Consequences + +The current GUI opens one local folder through a native picker on macOS, Windows, and Linux. Cancelling changes no state, failures remain retryable, and duplicate paths are idempotent while title conflicts require an explicit new choice. The selected workspace and its displayed name refresh before a new blank session starts. Existing workspace creation by name remains available. + +The added host, runtime, component, and GUI tests cover the native boundary, request trust checks, cancellation and failure handling, existing-path reuse, title conflicts, and the immediate visible-name update. The privileged RPC remains specific to the local desktop carrier; a remote Web directory browser is outside this decision. + +## Risks + +- Linux desktop environments may provide neither supported picker. The GUI reports that limitation instead of falling back to a typed path. +- Browser metadata varies outside the supported local carrier. The endpoint intentionally rejects requests that cannot prove the required local same-origin context. diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md new file mode 100644 index 0000000000..e484a2ba58 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 原生工作区目录选择器 + +Status: implemented + +[English](2026-07-27-native-workspace-directory-picker.md) | 中文 + +## 问题 + +桌面端 GUI 在添加现有工作区时要求用户输入绝对路径。相比使用操作系统原生选择器选取目录,这种操作速度更慢,也更容易出错。GUI 由本地 Web 载体提供,因此打开原生对话框也会形成一条特权边界,普通远程请求不得越过这条边界。 + +## 决策 + +新增一个用于选择单个文件夹的 `host.pickDirectory` RPC,并通过 `WorkspacesService` 暴露该 RPC。工作区菜单提供两个平铺操作:**打开本地文件夹…** 和 **创建新工作区**。选定文件夹后,系统复用现有的 `workspace.create({ path })` 流程,选中返回的工作区,并启动一个空白会话。 + +工作区管理器必须在选择回调运行前插入或更新返回的工作区。因此,新纳入的目录会立即显示其 basename。再次打开已注册的路径时,则保留该工作区现有的标题。 + +## 交互契约 + +- 在 macOS、Windows 和 Linux 上,选择器一次只允许选择一个目录。 +- 取消系统对话框不会显示提示,并返回 `null`。 +- 路径重复时,选中现有工作区。 +- 如果路径不同,但其派生标题与另一个工作区冲突,则显示明确指出该冲突的错误提示,其中包含 **重新选择** 和 **取消** 操作。 +- 选择器的其他故障会显示简洁且可重试的错误提示。 +- 现有的按名称创建流程保持不变。 + +## 宿主边界 + +只有来自回环套接字、且携带同源浏览器元数据的请求才能调用原生对话框 RPC。该 RPC 不使用默认的 30 秒请求超时,因为系统对话框可能无限期保持打开;调用方中止或连接中止仍会传递至平台进程。 + +平台适配器不经 shell,直接调用原生工具: + +- macOS:`osascript` 和系统文件夹选择器。 +- Windows:采用 STA 模式的 PowerShell 和 `FolderBrowserDialog`。 +- Linux:使用 `zenity`;Zenity 不可用时回退到 `kdialog`。 + +## 考虑过的替代方案 + +- 自定义目录浏览器会重复实现操作系统的行为和权限逻辑,而且应属于 Web 实现,而非本次仅面向桌面端的变更。 +- 继续使用手动路径字段会保留当前容易出错的交互方式。 +- 为一个本地原生对话框添加身份认证基础设施,会使变更范围超出其威胁模型;对当前载体而言,回环与同源检查已经足够。 + +## 后果 + +当前 GUI 可以在 macOS、Windows 和 Linux 上通过原生选择器打开一个本地文件夹。取消操作不会改变任何状态,故障仍可重试;重复路径的处理具有幂等性,标题冲突则要求用户明确重新选择。选中的工作区及其显示名称会在启动新的空白会话前完成刷新。现有的按名称创建工作区功能仍可使用。 + +新增的宿主、运行时、组件和 GUI 测试覆盖原生边界、请求信任校验、取消与故障处理、已有路径复用、标题冲突和可见名称即时更新。该特权 RPC 仍仅面向本地桌面载体;远程 Web 目录浏览器不属于本次决策范围。 + +## 风险 + +- Linux 桌面环境可能不提供任何一种受支持的选择器。GUI 会报告这项限制,而不会回退到要求用户输入路径。 +- 在受支持的本地载体之外,浏览器元数据可能有所不同。对于无法证明其满足所需本地同源上下文的请求,该端点会按设计拒绝。 diff --git a/apps/web/tests/slash-flow.snapshot.ts b/apps/web/tests/slash-flow.snapshot.ts index 29c1d68f7a..ac47ca4cd0 100644 --- a/apps/web/tests/slash-flow.snapshot.ts +++ b/apps/web/tests/slash-flow.snapshot.ts @@ -128,7 +128,6 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on- // Session+Agent and the provider swaps in the live blank-session hero. fireEvent.click(screen.getAllByRole('button', { name: 'Choose workspace' }) .find(el => el.getAttribute('aria-haspopup') === 'menu')!) - fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' })) fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' })) const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' }) fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), { diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 8041df307d..5521d984da 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -45,7 +45,6 @@ export function probeFreePort(): Promise { */ export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise { await page.getByRole('button', { name: 'Choose workspace' }).click() - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() const dialog = page.getByRole('dialog', { name: 'Create a new workspace' }) await dialog.waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 0166c6a9d9..e1fe183ef1 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -134,10 +134,9 @@ function setComposerText(composer: HTMLElement, value: string): void { expect((composer as HTMLTextAreaElement).value).toBe(value) } -/** Drive the picker's create flow: chip → Create workspace → name dialog. */ +/** Drive the picker's create flow: chip → Create a new workspace → name dialog. */ async function createWorkspaceViaPicker(name: string): Promise { fireEvent.click(workspaceChip()) - fireEvent.click(await screen.findByRole('menuitem', { name: 'Create workspace' })) fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' })) const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' }) fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), { diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 98a2338064..fd18d89087 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -30,9 +30,14 @@ describe('web e2e: workspace management (create / rename / flat view / hover car let browser: Browser let page: Page let tripwire: ReturnType + let pickedDirectory: string | null = null beforeAll(async () => { scaffold = await launchWebScaffold({}) + scaffold.ctx.apiProxy.host.pickDirectory = request => Promise.resolve({ + rpcId: request.rpcId, + result: { ok: true, value: { path: pickedDirectory } }, + }) // Seed one cold session (Ungrouped bucket) for the flat view + hover card. const sessionCwd = join(scaffold.workspaceCwd, 'workspace') await mkdir(sessionCwd, { recursive: true }) @@ -55,8 +60,6 @@ describe('web e2e: workspace management (create / rename / flat view / hover car onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-create')) const createByName = async (name: string): Promise => { await page.getByRole('button', { name: 'Create workspace' }).click() - // The pick menu's Create workspace submenu opens on hover/focus. - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() const dialog = page.getByRole('dialog', { name: 'Create a new workspace' }) await dialog.waitFor({ timeout: 10_000 }) @@ -134,14 +137,14 @@ describe('web e2e: workspace management (create / rename / flat view / hover car collect() }) // Register the scaffold's existing project directory through the real UI. + pickedDirectory = scaffold.workspaceCwd await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() - await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() - const useFolder = page.getByRole('dialog', { name: 'Use an existing folder' }) - await useFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd) - await useFolder.getByRole('button', { name: 'Use folder' }).click() - await expect.poll(() => useFolder.count(), { timeout: 10_000 }).toBe(0) + await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + await expect.poll( + () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd), + { timeout: 10_000 }, + ).not.toBeUndefined() const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) if (workspace === undefined) throw new Error('GUI did not register the existing project directory') await workspace.attachSession(SessionId(SEED_ID)) @@ -197,13 +200,13 @@ describe('web e2e: workspace management (create / rename / flat view / hover car // Re-registering the exact deleted path immediately, without a reload, is // a supported reversible flow. It creates a fresh Workspace id without // re-adopting the retained Session. + pickedDirectory = scaffold.workspaceCwd await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() - await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() - const reuseFolder = page.getByRole('dialog', { name: 'Use an existing folder' }) - await reuseFolder.getByLabel('Existing folder path').fill(scaffold.workspaceCwd) - await reuseFolder.getByRole('button', { name: 'Use folder' }).click() - await expect.poll(() => reuseFolder.count(), { timeout: 10_000 }).toBe(0) + await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + await expect.poll( + () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd), + { timeout: 10_000 }, + ).not.toBeUndefined() const reregistered = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) expect(reregistered?.id).toBeDefined() expect(reregistered?.id).not.toBe(workspace.id) @@ -269,13 +272,13 @@ describe('web e2e: workspace management (create / rename / flat view / hover car collect() }) + pickedDirectory = oldPath await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() - await page.getByRole('menuitem', { name: 'Use an existing folder' }).click() - const adopt = page.getByRole('dialog', { name: 'Use an existing folder' }) - await adopt.getByLabel('Existing folder path').fill(oldPath) - await adopt.getByRole('button', { name: 'Use folder' }).click() - await expect.poll(() => adopt.count(), { timeout: 10_000 }).toBe(0) + await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + await expect.poll( + () => scaffold.ctx.workspace.resolveByPath(oldPath), + { timeout: 10_000 }, + ).not.toBeUndefined() const oldWorkspace = await scaffold.ctx.workspace.resolveByPath(oldPath) if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered') @@ -288,7 +291,6 @@ describe('web e2e: workspace management (create / rename / flat view / hover car await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined() await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Create workspace' }).hover() await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() const create = page.getByRole('dialog', { name: 'Create a new workspace' }) await create.getByLabel('New workspace name').fill(title) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 9aa43145ed..f53f7ac22e 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -704,6 +704,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }, host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }), + pickDirectory: request => ok(request, { path: null }), }, workspace: { list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }), @@ -952,6 +953,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.prompt': return this.api.sessions.prompt(request) case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) + case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) case 'workspace.rename': return this.api.workspace.rename(request) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index f06b2bd2f6..bc73e0e054 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -5,6 +5,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' +import { isTrustedNativeDialogRequest } from './native-dialog-request.ts' export { API_PATH } from './api-path.ts' @@ -23,7 +24,16 @@ export function apply(ctx: Context): void { const route: WebRoute = { kind: 'prefix', path: API_PATH, - handler: (req, res) => bridge(req, res, apiHandler), + handler: async (req, res) => { + const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname + if (pathname === `${API_PATH}/host.pickDirectory` + && !isTrustedNativeDialogRequest(req)) { + res.writeHead(403) + res.end('forbidden') + return + } + await bridge(req, res, apiHandler) + }, } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') } diff --git a/packages/client/connection/src/native-dialog-request.ts b/packages/client/connection/src/native-dialog-request.ts new file mode 100644 index 0000000000..fe91bbae2d --- /dev/null +++ b/packages/client/connection/src/native-dialog-request.ts @@ -0,0 +1,52 @@ +/** Trust check for browser requests that can open an operating-system dialog. */ + +import type { IncomingHttpHeaders } from 'node:http' + +interface NativeDialogRequest { + headers: IncomingHttpHeaders + socket: { remoteAddress?: string | undefined } +} + +function header(headers: IncomingHttpHeaders, name: string): string | undefined { + const value = headers[name] + return typeof value === 'string' ? value : undefined +} + +function isLoopback(address: string | undefined): boolean { + if (address === undefined) return false + if (address === '::1') return true + const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address + const first = ipv4.split('.')[0] + return first === '127' +} + +function isLoopbackHostname(hostname: string): boolean { + if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true + const parts = hostname.split('.') + return parts.length === 4 + && parts[0] === '127' + && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) +} + +/** + * Require a local socket plus browser-controlled same-origin metadata. + * @param request - the node HTTP request facts used by the carrier guard. + * @returns true only for a same-origin browser request whose peer and URL are loopback. + */ +export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean { + if (!isLoopback(request.socket.remoteAddress)) return false + if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false + const origin = header(request.headers, 'origin') + const host = header(request.headers, 'host') + if (origin === undefined || host === undefined) return false + try { + const parsed = new URL(origin) + const hostUrl = new URL(`http://${host}`) + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') + && parsed.host === host + && isLoopbackHostname(parsed.hostname) + && isLoopbackHostname(hostUrl.hostname) + } catch { + return false + } +} diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 1c58e604ab..cfabe476e3 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -52,6 +52,8 @@ export class FakeApiClient implements IApiClient { onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) + onPickDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: null })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -70,6 +72,7 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), + pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), } readonly workspace: IApiClient['workspace'] = { diff --git a/packages/client/connection/tests/http-bridge.spec.ts b/packages/client/connection/tests/http-bridge.spec.ts new file mode 100644 index 0000000000..4607f32bae --- /dev/null +++ b/packages/client/connection/tests/http-bridge.spec.ts @@ -0,0 +1,47 @@ +import { EventEmitter } from 'node:events' +import { Readable } from 'node:stream' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { describe, expect, it } from 'vitest' +import { bridge } from '../src/http-bridge.ts' + +describe('HTTP bridge abort', () => { + it('aborts a pending native picker request when the browser disconnects', async () => { + const body = JSON.stringify({ + type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {}, + }) + const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage + Object.assign(request, { + url: '/api/host.pickDirectory', + method: 'POST', + headers: { 'content-type': 'application/json' }, + }) + + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead() { return this }, + write() { return true }, + end() { this.writableEnded = true; return this }, + }) as unknown as ServerResponse + + let resolveStarted!: () => void + const started = new Promise((resolve) => { resolveStarted = resolve }) + let carrierSignal: AbortSignal | undefined + const pending = bridge(request, response, { + fetch: async (input) => { + const fetchRequest = input as Request + carrierSignal = fetchRequest.signal + resolveStarted() + if (!fetchRequest.signal.aborted) { + await new Promise((resolve) => { + fetchRequest.signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + return Response.json({ aborted: fetchRequest.signal.aborted }) + }, + }) + await started + response.emit('close') + await pending + expect(carrierSignal?.aborted).toBe(true) + }) +}) diff --git a/packages/client/connection/tests/native-dialog-request.spec.ts b/packages/client/connection/tests/native-dialog-request.spec.ts new file mode 100644 index 0000000000..8048f6da83 --- /dev/null +++ b/packages/client/connection/tests/native-dialog-request.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts' + +function request( + remoteAddress: string, + headers: Record = { + host: '127.0.0.1:3080', + origin: 'http://127.0.0.1:3080', + 'sec-fetch-site': 'same-origin', + }, +) { + return { socket: { remoteAddress }, headers } +} + +describe('native dialog request trust', () => { + it('accepts loopback same-origin browser requests', () => { + expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true) + expect(isTrustedNativeDialogRequest(request('::1', { + host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(true) + expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(true) + }) + + it('rejects remote sockets and requests without matching browser metadata', () => { + expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + }) +}) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index e9e880cfb4..86af61ba0d 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,6 +1,7 @@ /** Node half: registers the /api prefix route bridging to the api gateway. */ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' +import type { IncomingMessage, ServerResponse } from 'node:http' import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { API_PATH, apply, inject } from '../src/index.ts' @@ -27,6 +28,23 @@ describe('connection node half', () => { expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) + let status: number | undefined + let body: unknown + const deniedRequest = { + url: '/api/host.pickDirectory', + headers: { + host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', + }, + socket: { remoteAddress: '192.168.1.8' }, + } as unknown as IncomingMessage + const deniedResponse = { + writeHead(value: number) { status = value; return this }, + end(value?: unknown) { body = value; return this }, + } as unknown as ServerResponse + await routes[0]!.handler(deniedRequest, deniedResponse) + expect(status).toBe(403) + expect(body).toBe('forbidden') + await fiber.dispose() expect(routes).toHaveLength(0) }) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b4a418628f..ec39ce9e1c 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -13,7 +13,7 @@ export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' -export { WorkspacesService } from './workspaces/service.ts' +export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' export type { Session } from './sessions/session.ts' export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 4cb26aa220..fe345801c6 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -21,6 +21,14 @@ export interface WorkspaceListState { recentWorkspaceId: WorkspaceId | undefined } +/** Structured create failure for UI flows that distinguish Host business errors. */ +export class WorkspaceCreateError extends Error { + constructor(readonly rpcError: RpcError) { + super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`) + this.name = 'WorkspaceCreateError' + } +} + /** Real Workspace object layer and Host actions. */ export class WorkspacesService { /** UI-facing immutable projection; the manager remains wire truth. */ @@ -37,7 +45,7 @@ export class WorkspacesService { * @param api - shared wire client. * @param sessions - lower-level Session service used for recency and blank-session reuse. */ - constructor(ctx: Context, api: IApiClient, private readonly sessions: SessionsService) { + constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsService) { this.manager = new WorkspaceManager(api) this.list = createSnapshotStore({ items: [], state: 'idle', phase: 'pending', error: null, @@ -158,10 +166,22 @@ export class WorkspacesService { */ async create(input: { name: string } | { path: string }): Promise { const result = await this.manager.create(input) - if (!result.ok) throw new Error(`workspace create failed: ${result.error.code}: ${result.error.message}`) + if (!result.ok) throw new WorkspaceCreateError(result.error) return result.value.workspace } + /** + * Open the Host's native directory picker. + * @returns the selected path, or null when the user cancelled. + */ + async pickDirectory(): Promise { + const response = await this.api.host.pickDirectory({}) + if (!response.result.ok) { + throw new Error(`directory picker failed: ${response.result.error.message}`) + } + return response.result.value.path + } + /** * Rename a Workspace. * @param workspaceId - target workspace. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index d906ff92a8..6987d82d7a 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -69,6 +69,8 @@ export class FakeApiClient implements IApiClient { onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) + onPickDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: null })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -87,6 +89,7 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), + pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), } onWorkspaceList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 210c04d896..5327066651 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' import { SessionsService } from '../src/client/sessions/service.ts' import { WorkspaceManager } from '../src/client/workspaces/manager.ts' -import { WorkspacesService } from '../src/client/workspaces/service.ts' +import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' const sid = (id: string): SessionId => id as SessionId @@ -210,12 +210,30 @@ describe('WorkspacesService', () => { const api = new FakeApiClient() const sessions = new SessionsService(ctx, api) const workspaces = new WorkspacesService(ctx, api, sessions) - await expect(workspaces.create({ path: '/w/existing' })).resolves.toMatchObject({ workspaceId: 'fk-ws' }) - expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/existing' }]) + api.onWorkspaceCreate = () => Promise.resolve(ok({ + workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true, + })) + await expect(workspaces.create({ path: '/w/alpha' })).resolves.toMatchObject({ workspaceId: 'picked' }) + expect(workspaces.list.getSnapshot().items[0]).toMatchObject({ path: '/w/alpha', title: 'alpha' }) + expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/alpha' }]) api.onWorkspaceCreate = () => Promise.resolve(err({ code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' }, })) - await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/) + const rejected = workspaces.create({ path: '/missing' }) + await expect(rejected).rejects.toThrow(/workspace-invalid-path: missing/) + await expect(rejected).rejects.toBeInstanceOf(WorkspaceCreateError) + }) + + it('passes native directory selection and cancellation through without local state', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' })) + await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha') + api.onPickDirectory = () => Promise.resolve(ok({ path: null })) + await expect(workspaces.pickDirectory()).resolves.toBeNull() + expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}]) }) it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 7a229758b1..789f86aeb6 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -90,20 +90,17 @@ export function apply(ctx: Context): void { 'conversation.hero.workspace': { kind: 'single', scope: 'root' }, }, inject: (sessionId: SessionId | undefined): ConversationInjected => ({ - selectWorkspace: (workspaceId) => { - void workspaces.connectWorkspace(workspaceId).then((nextId) => { - if (sessionId !== undefined && nextId !== sessionId) { - const from = inputHub.shell(sessionId) - const draft = from.snapshot.draft - if (draft !== '') { - inputHub.shell(nextId).setDraft(draft) - from.setDraft('') - } + selectWorkspace: async (workspaceId) => { + const nextId = await workspaces.connectWorkspace(workspaceId) + if (sessionId !== undefined && nextId !== sessionId) { + const from = inputHub.shell(sessionId) + const draft = from.snapshot.draft + if (draft !== '') { + inputHub.shell(nextId).setDraft(draft) + from.setDraft('') } - sessions.open(nextId) - }).catch(() => { - // Failure leaves the current Hero state available to retry. - }) + } + sessions.open(nextId) }, }), }, ConversationRoot) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1e620f0905..9086e11d7c 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -175,7 +175,7 @@ export interface ConversationInjected { * Connect the selected Workspace and open its reusable/new blank session. * When a blank session is already current, carry its draft to the target. */ - selectWorkspace(workspaceId: WorkspaceId): void + selectWorkspace(workspaceId: WorkspaceId): Promise } /** Business callbacks injected into the strict session content seat. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 5f9e91a049..780e74be4c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -2,8 +2,9 @@ // chain stay mounted across no-session/session transitions. Only the inert // input body swaps for the strict session InputBar. -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' +import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import { DisabledInputBar } from './DisabledInputBar.tsx' @@ -25,8 +26,23 @@ export function ConversationRoot({ const workspaces = useWorkspaces(s => s) const [pickerOpen, setPickerOpen] = useState(false) + const [pendingWorkspaceId, setPendingWorkspaceId] = useState() const pickerAnchor = useRef(null) + const sessionWorkspace = sessionId === undefined + ? undefined + : workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId)) + const pendingWorkspace = workspaces.items.find( + workspace => workspace.workspaceId === pendingWorkspaceId, + ) + + useEffect(() => { + if (pendingWorkspaceId !== undefined + && sessionWorkspace?.workspaceId === pendingWorkspaceId) { + setPendingWorkspaceId(undefined) + } + }, [pendingWorkspaceId, sessionWorkspace?.workspaceId]) + const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading')) const zone: InputZone | undefined = session === undefined || inputState === undefined ? undefined : { session, input: inputState } @@ -36,9 +52,10 @@ export function ConversationRoot({ w.sessionIds.includes(sessionId))?.title ?? workspaceLabel(cwd ?? '') + : sessionWorkspace?.title ?? workspaceLabel(cwd ?? '')) } menuOpen={pickerOpen} onClick={() => { setPickerOpen(open => !open) }} @@ -48,7 +65,10 @@ export function ConversationRoot({ anchorRef: pickerAnchor, onPick: (workspaceId) => { setPickerOpen(false) - selectWorkspace(workspaceId) + setPendingWorkspaceId(workspaceId) + void selectWorkspace(workspaceId).catch(() => { + setPendingWorkspaceId(current => current === workspaceId ? undefined : current) + }) }, onClose: () => { setPickerOpen(false) }, })} diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 9b1eac5b93..b777c85ac3 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -3,7 +3,7 @@ // hero (blank session) and active phases — same textarea DOM node, machine- // owned draft, and the hero workspace picker (switching = retargetWorkspace). import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { @@ -55,7 +55,11 @@ function conversationSnapshot(overrides: Partial = {}): Co } } -function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }]) { +function mount( + snapshot: ConversationSnapshot, + workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }], + retargetWorkspace = vi.fn(async (_workspaceId: WorkspaceId) => {}), +) { const root = sid('root') const sessions = createSnapshotStore({ ids: [root, SID], @@ -76,7 +80,6 @@ function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = const inputActions = wiring.actions const stop = vi.fn() const open = vi.fn() - const retargetWorkspace = vi.fn() const slotCalls: string[] = [] let pickerOwner: unknown const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => { @@ -158,7 +161,13 @@ describe('ConversationRoot resident composer', () => { }) it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => { - const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true })) + const b = mount( + conversationSnapshot({ composerPhase: 'blank', blank: true }), + [ + { ...workspace('one'), sessionIds: [SID] }, + { ...workspace('second'), title: 'Selected Folder' }, + ], + ) // Hero chrome present, view ring absent. expect(b.view.getByText("Let's start building")).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() @@ -173,8 +182,9 @@ describe('ConversationRoot resident composer', () => { fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' })) const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void } expect(owner.open).toBe(true) - owner.onPick(wid('second')) + act(() => { owner.onPick(wid('second')) }) expect(b.retargetWorkspace).toHaveBeenCalledWith(wid('second')) + expect(b.view.getByText('Selected Folder')).toBeTruthy() }) it('textarea DOM identity survives the hero → active flip', () => { @@ -191,6 +201,24 @@ describe('ConversationRoot resident composer', () => { expect(b.view.getByTestId('view-chat')).toBeTruthy() }) + it('rolls the pending workspace label back when switching fails', async () => { + const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') }) + const b = mount( + conversationSnapshot({ composerPhase: 'blank', blank: true }), + [ + { ...workspace('one'), sessionIds: [SID] }, + { ...workspace('second'), title: 'Selected Folder' }, + ], + selectWorkspace, + ) + fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' })) + const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void } + await act(async () => { owner.onPick(wid('second')); await Promise.resolve() }) + expect(selectWorkspace).toHaveBeenCalledWith(wid('second')) + expect(b.view.queryByText('Selected Folder')).toBeNull() + expect(b.view.getByText('one')).toBeTruthy() + }) + it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => { const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true })) const chip = b.view.getByRole('button', { name: 'Choose workspace' }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 88638b9e35..0d78a8d648 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -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 packages/client/ui-workspace/README.md -README.md: b5a78c30ddae5e12612bb8cced65b5fe95f7e259 -README.zh.md: 904543a48f1609e23ba80cf240be965d0654a951 +README.md: edd6c2f9373d97832def86bb44658d7c1c68dae9 +README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index b5a78c30dd..edd6c2f937 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Shared Workspace picker plugin. `WorkspacePicker` is registered into the sidebar's `sidebar.workspace` slot and the page-local Session Intent hero's `conversation.empty.workspace` slot, so both surfaces use the same menu and creation modals. +Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object; the existing-folder and create-new actions first create a real Workspace through the object layer, then select it. Create-new disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. @@ -19,4 +19,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions. -- **Existing-folder entry is manual path input only** — Host creation failures are shown in the modal. +- **Native folder selection depends on the local Host carrier** — fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 904543a48f..f7b73dde95 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -共享 Workspace 选择器插件。`WorkspacePicker` 注册到侧边栏的 `sidebar.workspace` slot,以及页面局部 Session Intent 主视觉区的 `conversation.empty.workspace` slot,因此两个表层使用同一菜单和创建模态框。 +共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象;使用现有文件夹和新建操作时,系统会先通过对象层创建真实 Workspace,再将其选中。新建操作会禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 @@ -19,4 +19,4 @@ ## 已知限制与暂缓事项 - **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。 -- **现有文件夹入口仅支持手动输入路径**:Host 创建失败会显示在模态框中。 +- **原生文件夹选择依赖本地 Host 载体**:仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index c56de93c56..c5609b176a 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -250,6 +250,7 @@ export function WorkspaceBrowser({ deleteWorkspace, insertSessionBefore, createWorkspace, + pickDirectory, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) const groupBy = useStore(s => s.groupBy) @@ -367,6 +368,7 @@ export function WorkspaceBrowser({ anchorRef={wsPlusRef} useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} + pickDirectory={pickDirectory} onPick={(workspaceId) => { setWsPickerOpen(false) startSession(workspaceId) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 99ed2831a6..6b173b8987 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -9,15 +9,17 @@ import { useCallback, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { WorkspaceId, WorkspaceListState, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import { + WorkspaceCreateError, + type WorkspaceId, type WorkspaceListState, type WorkspaceView, +} from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspacePickerProps } from './contract/slots.ts' import css from './WorkspacePicker.module.css' -const CREATE_WORKSPACE = '::create-workspace' -const USE_EXISTING = '::use-existing' +const OPEN_LOCAL_FOLDER = '::open-local-folder' const CREATE_NEW = '::create-new' -type ModalKind = 'path' | 'create' | null +type ModalKind = 'create' | 'folder-error' | null /** Core flow props: the owner supplies popover control and pick semantics. */ export interface WorkspaceCreateFlowProps { @@ -29,6 +31,8 @@ export interface WorkspaceCreateFlowProps { useWorkspaces: (selector: (state: WorkspaceListState) => S) => S /** Create or adopt a real Host Workspace. */ createWorkspace: (input: { name: string } | { path: string }) => Promise + /** Open the Host's native single-directory picker. */ + pickDirectory: () => Promise /** A real Workspace was picked or created. */ onPick: (workspaceId: WorkspaceId) => void /** Close the popover (outside click / Escape / post-pick). */ @@ -45,6 +49,7 @@ export function WorkspaceCreateFlow({ anchorRef, useWorkspaces, createWorkspace, + pickDirectory, onPick, onClose, }: WorkspaceCreateFlowProps) { @@ -55,10 +60,11 @@ export function WorkspaceCreateFlow({ [anchorRef], ) const [modalKind, setModalKind] = useState(null) - const [pathDraft, setPathDraft] = useState('') const [workspaceName, setWorkspaceName] = useState('') const [creating, setCreating] = useState(false) const [modalError, setModalError] = useState(null) + const [pickingFolder, setPickingFolder] = useState(false) + const [folderConflict, setFolderConflict] = useState(false) const normalizedWorkspaceName = workspaceName.trim() const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) @@ -68,17 +74,11 @@ export function WorkspaceCreateFlow({ id: workspace.workspaceId as string, label: workspace.title, icon: , + disabled: pickingFolder, })), ...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []), - { - id: CREATE_WORKSPACE, - label: 'Create workspace', - icon: , - submenu: [ - { id: USE_EXISTING, label: 'Use an existing folder' }, - { id: CREATE_NEW, label: 'Create a new workspace' }, - ], - }, + { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, + { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, ] const closeModal = (): void => { @@ -87,12 +87,31 @@ export function WorkspaceCreateFlow({ setModalError(null) } + const openLocalFolder = (): void => { + if (pickingFolder) return + onClose() + setModalKind(null) + setModalError(null) + setFolderConflict(false) + setPickingFolder(true) + void pickDirectory().then(async (path) => { + if (path === null) return + const workspace = await createWorkspace({ path }) + onPick(workspace.workspaceId) + }).catch((reason: unknown) => { + setFolderConflict( + reason instanceof WorkspaceCreateError + && reason.rpcError.code === 'workspace-name-conflict', + ) + setModalError(reason instanceof Error ? reason.message : String(reason)) + setModalKind('folder-error') + }).finally(() => { setPickingFolder(false) }) + } + const handleSelect = (id: string): void => { - if (id === USE_EXISTING) { - onClose() - setPathDraft('') - setModalError(null) - setModalKind('path') + if (pickingFolder) return + if (id === OPEN_LOCAL_FOLDER) { + openLocalFolder() return } if (id === CREATE_NEW) { @@ -120,11 +139,6 @@ export function WorkspaceCreateFlow({ }) } - const confirmPath = (): void => { - const path = pathDraft.trim() - if (path !== '') create({ path }) - } - const confirmCreate = (): void => { if (normalizedWorkspaceName !== '' && !duplicateWorkspaceName) { create({ name: normalizedWorkspaceName }) @@ -144,40 +158,21 @@ export function WorkspaceCreateFlow({ /> {open && workspaceSnapshot.phase === 'pending' &&
Loading workspaces…
} - - + + )} > - { setPathDraft(event.target.value) }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - confirmPath() - } - }} - /> - {creating &&
Creating workspace…
} - {modalError !== null &&
{modalError}
} +
+ {folderConflict + ? 'Choose a folder with a different name.' + : modalError} +
diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 2e4c88dd6b..1f2f3aae70 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -42,6 +42,8 @@ export type WorkspaceBrowserInjected = { insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace: (input: { name: string } | { path: string }) => Promise + /** Ask the local Host to open its native single-directory picker. */ + pickDirectory: () => Promise } /** Full browser props: shell owner share + viewing store + injected actions. */ @@ -58,6 +60,8 @@ export type WorkspaceBrowserProps = export type WorkspacePickerInjected = { /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace(input: { name: string } | { path: string }): Promise + /** Ask the local Host to open its native single-directory picker. */ + pickDirectory(): Promise } /** diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 98adfecce3..f27448f926 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -44,9 +44,11 @@ export function apply(ctx: ClientContext): void { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => ctx.workspaces.create(input), + pickDirectory: () => ctx.workspaces.pickDirectory(), }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => ctx.workspaces.create(input), + pickDirectory: () => ctx.workspaces.pickDirectory(), }) // Declaration-aware registration: each owner's declaring apply may activate // after this one (entry activation order is unconstrained), and a register diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 8961ccdb83..24434f22aa 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -14,16 +14,17 @@ async function bench() { path: 'name' in input ? `/projects/${input.name}` : input.path, title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0', })) + const pickDirectory = vi.fn(async () => '/tmp/picked') const startSession = vi.fn() const rename = vi.fn(async () => ({})) const insertSessionBefore = vi.fn(async () => ({})) const open = vi.fn() const clear = vi.fn() ctx.provide('workspaces', { - create, startSession, rename, insertSessionBefore, + create, pickDirectory, startSession, rename, insertSessionBefore, } as never) ctx.provide('sessions', { open, clear } as never) - return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear } + return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, startSession, rename, insertSessionBefore, open, clear } } type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace' @@ -72,10 +73,14 @@ describe('ui-workspace apply', () => { expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2') await browser.createWorkspace({ name: 'project' }) expect(b.create).toHaveBeenCalledWith({ name: 'project' }) + await browser.pickDirectory() + expect(b.pickDirectory).toHaveBeenCalledOnce() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() await picker.createWorkspace({ path: '/tmp/project' }) expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' }) + await picker.pickDirectory() + expect(b.pickDirectory).toHaveBeenCalledTimes(2) }) it('unregisters every entry on teardown', async () => { diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 33abdc1231..1f06055ece 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -57,6 +57,7 @@ function mount(overrides: Partial = {}) { deleteWorkspace: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), + pickDirectory: vi.fn(async () => null), ...overrides, } const view = render() diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index a5510178e7..99c5e71b48 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -4,6 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import type { SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' +import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' afterEach(cleanup) @@ -32,7 +33,11 @@ function anchor(): { current: HTMLElement } { return { current: element } } -function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn()) { +function mount( + items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], + createWorkspace = vi.fn(), + pickDirectory = vi.fn(async () => null as string | null), +) { const onPick = vi.fn() const onClose = vi.fn() const anchorRef = anchor() @@ -45,20 +50,19 @@ function mount(items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], onPick={onPick} onClose={onClose} createWorkspace={createWorkspace} + pickDirectory={pickDirectory} /> ) const view = render( renderPicker(items), ) return { - view, onPick, onClose, createWorkspace, + view, onPick, onClose, createWorkspace, pickDirectory, rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) }, } } -function chooseCreateItem(name: 'Use an existing folder' | 'Create a new workspace'): void { - const parent = screen.getByRole('menuitem', { name: 'Create workspace' }) - fireEvent.mouseEnter(parent.parentElement as HTMLElement) +function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): void { fireEvent.click(screen.getByRole('menuitem', { name })) } @@ -73,7 +77,7 @@ describe('WorkspacePicker', () => { const created = workspace('new', 'New') const createWorkspace = vi.fn(async () => created) const b = mount([], createWorkspace) - chooseCreateItem('Create a new workspace') + chooseItem('Create a new workspace') const input = screen.getByLabelText('New workspace name') fireEvent.change(input, { target: { value: 'project-one' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) @@ -81,31 +85,69 @@ describe('WorkspacePicker', () => { await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) }) - it('adopts an existing path through the same immediate create action', async () => { - const created = workspace('adopted') + it('opens a native directory picker, adopts its path, and selects the returned Workspace', async () => { + const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' } const createWorkspace = vi.fn(async () => created) - const b = mount([], createWorkspace) - chooseCreateItem('Use an existing folder') - const input = screen.getByLabelText('Existing folder path') - fireEvent.keyDown(input, { key: 'ArrowRight' }) - fireEvent.keyDown(input, { key: 'Enter' }) - expect(createWorkspace).not.toHaveBeenCalled() - fireEvent.change(input, { target: { value: ' /tmp/project ' } }) - fireEvent.keyDown(input, { key: 'Enter' }) + const pickDirectory = vi.fn(async () => '/tmp/project') + const b = mount([], createWorkspace, pickDirectory) + chooseItem('Open local folder…') + expect(pickDirectory).toHaveBeenCalledOnce() + await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) }) expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) }) + it('treats native picker cancellation as a silent no-op', async () => { + const b = mount([], vi.fn(), vi.fn(async () => null)) + chooseItem('Open local folder…') + await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) + expect(b.createWorkspace).not.toHaveBeenCalled() + expect(b.onPick).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('shows a name conflict and retries through the native picker', async () => { + const pickDirectory = vi.fn() + .mockResolvedValueOnce('/one/project') + .mockResolvedValueOnce(null) + const createWorkspace = vi.fn(async () => { + throw new WorkspaceCreateError({ + code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' }, + }) + }) + const b = mount([], createWorkspace, pickDirectory) + chooseItem('Open local folder…') + await waitFor(() => { + expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy() + }) + expect(screen.getByRole('alert').textContent).toBe('Choose a folder with a different name.') + fireEvent.click(screen.getByRole('button', { name: 'Choose again' })) + await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) }) + expect(b.onPick).not.toHaveBeenCalled() + }) + + it('disables the folder action while the native picker is already open', async () => { + let resolve!: (path: string | null) => void + const pending = new Promise((settle) => { resolve = settle }) + const b = mount([], vi.fn(), vi.fn(() => pending)) + chooseItem('Open local folder…') + expect((screen.getByRole('menuitem', { name: 'Open local folder…' }) as HTMLButtonElement).disabled).toBe(true) + expect((screen.getByRole('menuitem', { name: 'Create a new workspace' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' })) + expect(b.pickDirectory).toHaveBeenCalledTimes(1) + await act(async () => { resolve(null); await pending }) + }) + it('closes a creation modal when the user cancels', () => { mount([]) - chooseCreateItem('Create a new workspace') + chooseItem('Create a new workspace') fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect(screen.queryByRole('dialog')).toBeNull() }) it('blocks a create-new name already present in the Workspace list', () => { const b = mount([workspace('alpha', 'Alpha')]) - chooseCreateItem('Create a new workspace') + chooseItem('Create a new workspace') fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } }) expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.') expect((screen.getByRole('button', { name: 'Create workspace' }) as HTMLButtonElement).disabled).toBe(true) @@ -118,7 +160,7 @@ describe('WorkspacePicker', () => { const pending = new Promise((settle) => { resolve = settle }) const created = workspace('fresh', 'same-name') const b = mount([], vi.fn(() => pending)) - chooseCreateItem('Create a new workspace') + chooseItem('Create a new workspace') fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) @@ -134,7 +176,7 @@ describe('WorkspacePicker', () => { const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise }) const createWorkspace = vi.fn(() => pending) const b = mount([], createWorkspace) - chooseCreateItem('Create a new workspace') + chooseItem('Create a new workspace') const input = screen.getByLabelText('New workspace name') fireEvent.keyDown(input, { key: 'ArrowRight' }) fireEvent.change(input, { target: { value: 'broken' } }) @@ -151,7 +193,7 @@ describe('WorkspacePicker', () => { it('reports non-Error creation failures', async () => { const b = mount([], vi.fn(async () => { throw 'permission denied' })) - chooseCreateItem('Create a new workspace') + chooseItem('Create a new workspace') fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied') @@ -163,7 +205,7 @@ describe('WorkspacePicker', () => { render( , ) expect(screen.queryByRole('menu')).toBeNull() @@ -176,7 +218,7 @@ describe('WorkspacePicker', () => { render( , ) expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index bf9f1194d4..aee09384de 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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 packages/host/apiproxy/README.md -README.md: adcffaf553bb62f77e38cf8f19f5b48e8d7881e1 -README.zh.md: 957b7967c95131f3da6cd25ce395fba8db57b020 +README.md: 253c0974cc1427fb7140c332fabdccbfc049ae86 +README.zh.md: d79628ca3e1f1d06ad94a0dada16e2208af3cd2c diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index adcffaf553..253c0974cc 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -14,6 +14,8 @@ The mux stream projects the latest log-backed title as a validated `session/titl Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier separately restricts this privileged method to loopback, same-origin requests. + `session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state. The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. @@ -35,3 +37,4 @@ None; this package neither assembles nor sends a provider request. - **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals). - **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. +- **Linux native picker requires desktop tooling** — `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; it does not fall back to a custom or typed-path browser. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 957b7967c9..d79628ca3e 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -14,6 +14,8 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体另行将这一特权方法限制为仅接受来自回环地址的同源请求。 + `session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。 `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 @@ -35,3 +37,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr - **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。 - **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 +- **Linux 原生选择器依赖桌面工具**:Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;它不会回退到自定义目录浏览器,也不会要求用户手动输入路径。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a357eee13b..eb7abab8e4 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -33,6 +33,7 @@ import type { AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest, } from '@deepseek-ai/dsh-user-interaction' import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' +import { pickNativeDirectory } from './native-directory-picker.ts' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 @@ -194,6 +195,8 @@ export interface ApiProxyDefaults { cwd: string /** Parent directory for name-created workspaces. */ workspaceRoot: string + /** Native single-directory picker; injectable for carrier tests. */ + pickDirectory?: (signal: AbortSignal) => Promise } /** The tool/call payload fields the presenter path reads. */ @@ -823,6 +826,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro attachedSessions: ctx.agents.list().length, })) }, + + async pickDirectory(request, signal) { + try { + const path = await (defaults.pickDirectory ?? pickNativeDirectory)(signal) + return ok(request, { path }) + } catch (error: unknown) { + if (signal.aborted) { + return err(request, { + code: 'cancelled', + message: 'directory picker was aborted', + details: {}, + }) + } + return err(request, { + code: 'internal', + message: `directory picker failed: ${error instanceof Error ? error.message : String(error)}`, + details: {}, + }) + } + }, }, commands: { diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index cb8de392f5..c7353c4360 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -17,3 +17,11 @@ export const hostDescribeValueSchema = z.object({ model: z.string().optional(), attachedSessions: z.number().int().nonnegative(), }) satisfies z.ZodType>> + +/** host.pickDirectory request payload (empty object literal). */ +export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType>> + +/** host.pickDirectory response value; null means the user cancelled. */ +export const hostPickDirectoryValueSchema = z.object({ + path: z.string().nullable(), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index fd07b33ced..e3573346f4 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -22,4 +22,10 @@ export interface HostApi { model?: string attachedSessions: number }>> + + /** Open the operating system's single-directory picker; cancellation returns null. */ + pickDirectory( + request: RpcRequest<{}>, + signal: AbortSignal, + ): Promise> } diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 68ccc9ec89..768c93ed2f 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -23,6 +23,7 @@ export interface RpcMethodMap { 'session.prompt': SessionsApi['prompt'] 'session.cancel': SessionsApi['cancel'] 'host.describe': HostApi['describe'] + 'host.pickDirectory': HostApi['pickDirectory'] 'workspace.list': WorkspaceApi['list'] 'workspace.create': WorkspaceApi['create'] 'workspace.rename': WorkspaceApi['rename'] diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 8762670cd7..445bb0d563 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -13,7 +13,7 @@ import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts' import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts' -import { hostDescribeValueSchema } from '../api/host.schema.ts' +import { hostDescribeValueSchema, hostPickDirectoryValueSchema } from '../api/host.schema.ts' import { sessionCancelValueSchema, sessionCreateValueSchema, @@ -56,6 +56,7 @@ export interface IApiClient { } host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> + pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise>> } workspace: { list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise>> @@ -90,6 +91,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType { - const timeout = AbortSignal.timeout(this.timeoutMs) + private async postJson( + path: string, + body: ClientRequest | ClientResponse, + signal: AbortSignal | undefined, + useDefaultTimeout = true, + ): Promise { + const requestSignal = useDefaultTimeout + ? signal === undefined + ? AbortSignal.timeout(this.timeoutMs) + : AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal]) + : signal const response = await this.doFetch(new URL(path, this.resolveBase()), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), - signal: signal === undefined ? timeout : AbortSignal.any([timeout, signal]), + ...requestSignal === undefined ? {} : { signal: requestSignal }, }) if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`) return response @@ -198,10 +209,11 @@ export abstract class AbstractApiClient implements IApiClient { method: K, payload: RequestPayload, signal?: AbortSignal, + useDefaultTimeout = true, ): Promise>> { const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload } this.onEnvelope(message) - const response = await this.postJson(`/api/${method}`, message, signal) + const response = await this.postJson(`/api/${method}`, message, signal, useDefaultTimeout) const full = serverResponseSchema.parse(await response.json()) this.onEnvelope(full) if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`) @@ -282,6 +294,9 @@ export abstract class AbstractApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload, signal) => this.callUnary('host.describe', payload, signal), + // A native system dialog is user-paced and may legitimately stay open + // longer than the normal unary deadline. Caller/connection aborts remain. + pickDirectory: (payload, signal) => this.callUnary('host.pickDirectory', payload, signal, false), } readonly workspace: IApiClient['workspace'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 3bbcbffba1..9b7bb5edda 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -21,7 +21,7 @@ import { sessionListRequestSchema, sessionPromptRequestSchema, } from '../api/sessions.schema.ts' -import { hostDescribeRequestSchema } from '../api/host.schema.ts' +import { hostDescribeRequestSchema, hostPickDirectoryRequestSchema } from '../api/host.schema.ts' import { workspaceCreateRequestSchema, workspaceDeleteRequestSchema, @@ -55,6 +55,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, + 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, diff --git a/packages/host/apiproxy/src/native-directory-picker.ts b/packages/host/apiproxy/src/native-directory-picker.ts new file mode 100644 index 0000000000..8bc0bc99ce --- /dev/null +++ b/packages/host/apiproxy/src/native-directory-picker.ts @@ -0,0 +1,136 @@ +/** Cross-platform native single-directory picker used by the local GUI carrier. */ + +import { execFile } from 'node:child_process' + +/** Testable command boundary; native implementations never invoke a shell. */ +export type DirectoryPickerRunner = ( + command: string, + args: readonly string[], + signal: AbortSignal, +) => Promise<{ stdout: string; stderr: string }> + +/** Injectable platform facts for deterministic adapter tests. */ +export interface DirectoryPickerInternals { + platform?: NodeJS.Platform + run?: DirectoryPickerRunner +} + +const runCommand: DirectoryPickerRunner = (command, args, signal) => + new Promise((resolve, reject) => { + execFile( + command, + [...args], + { encoding: 'utf8', signal, windowsHide: true }, + (error, stdout, stderr) => { + if (error !== null) { + const failure = Object.assign(new Error(error.message, { cause: error }), { + code: error.code, + stdout, + stderr, + }) + reject(failure) + return + } + resolve({ stdout, stderr }) + }, + ) + }) + +function outputPath(stdout: string): string | null { + const path = stdout.replace(/[\r\n]+$/, '') + return path === '' ? null : path +} + +function errorCode(error: unknown): string | number | undefined { + if (typeof error !== 'object' || error === null || !('code' in error)) return undefined + const code = (error as { code?: unknown }).code + return typeof code === 'string' || typeof code === 'number' ? code : undefined +} + +function errorStderr(error: unknown): string { + if (typeof error !== 'object' || error === null || !('stderr' in error)) return '' + const stderr = (error as { stderr?: unknown }).stderr + return typeof stderr === 'string' ? stderr : '' +} + +function isMissingCommand(error: unknown): boolean { + return errorCode(error) === 'ENOENT' +} + +function rethrowIfAborted(signal: AbortSignal, error: unknown): void { + if (signal.aborted) throw error +} + +/** + * Open the platform directory picker. + * @param signal - caller/connection lifetime; abort terminates the native command. + * @param internals - platform and runner seam for deterministic tests. + * @returns the selected path, or null when the user cancels. + */ +export async function pickNativeDirectory( + signal: AbortSignal, + internals: DirectoryPickerInternals = {}, +): Promise { + const platform = internals.platform ?? process.platform + const run = internals.run ?? runCommand + + if (platform === 'darwin') { + try { + const result = await run('osascript', [ + '-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', + '-e', 'POSIX path of selectedFolder', + ], signal) + return outputPath(result.stdout) + } catch (error: unknown) { + if (!signal.aborted && errorCode(error) === 1 + && /(?:User canceled|-128)/i.test(errorStderr(error))) return null + throw error + } + } + + if (platform === 'win32') { + const script = [ + "$ErrorActionPreference = 'Stop'", + 'Add-Type -AssemblyName System.Windows.Forms', + '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog', + "$dialog.Description = 'Select Workspace Directory'", + '$dialog.ShowNewFolderButton = $true', + '$result = $dialog.ShowDialog()', + 'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {', + ' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + ' [Console]::WriteLine($dialog.SelectedPath)', + '}', + ].join('; ') + const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal) + return outputPath(result.stdout) + } + + if (platform === 'linux') { + try { + const result = await run('zenity', [ + '--file-selection', '--directory', '--title=Select Workspace Directory', + ], signal) + return outputPath(result.stdout) + } catch (error: unknown) { + rethrowIfAborted(signal, error) + if (errorCode(error) === 1) return null + if (!isMissingCommand(error)) throw error + } + + try { + const result = await run('kdialog', [ + '--getexistingdirectory', '.', '--title', 'Select Workspace Directory', + ], signal) + return outputPath(result.stdout) + } catch (error: unknown) { + rethrowIfAborted(signal, error) + if (errorCode(error) === 1) return null + if (isMissingCommand(error)) { + throw new Error('no supported native directory picker found (install zenity or kdialog)') + } + throw error + } + } + + throw new Error(`native directory picker is unsupported on ${platform}`) +} diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index bbd57cb6dc..873971e338 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -57,6 +57,7 @@ function stubAgent(session: Session): Agent { /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ async function harness( workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), + pickDirectory?: (signal: AbortSignal) => Promise, ) { const ctx = new Context() await ctx.plugin(SessionStore) @@ -96,10 +97,33 @@ async function harness( model: 'test-model', cwd: workspaceRoot, workspaceRoot, + ...pickDirectory === undefined ? {} : { pickDirectory }, }) return { api, ctx, storageDomain, workspaceRoot } } +describe('host.pickDirectory', () => { + it('returns a selected path or explicit cancellation from the injected native boundary', async () => { + const selected = await harness(undefined, async () => '/tmp/project') + expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result) + .toEqual({ ok: true, value: { path: '/tmp/project' } }) + + const cancelled = await harness(undefined, async () => null) + expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result) + .toEqual({ ok: true, value: { path: null } }) + }) + + it('propagates abort into the native boundary as a cancelled RPC error', async () => { + const { api } = await harness(undefined, signal => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + })) + const abort = new AbortController() + const pending = api.host.pickDirectory(request({}), abort.signal) + abort.abort() + expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) + }) +}) + describe('workspace.create', () => { it('serializes concurrent names and rejects the duplicate', async () => { const { api, workspaceRoot } = await harness() @@ -131,6 +155,13 @@ describe('workspace.create', () => { expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } }) expect(repeated).toMatchObject({ created: false, workspace: { workspaceId: first.workspace.workspaceId } }) + expectOk(await api.workspace.rename(request({ + workspaceId: first.workspace.workspaceId, + title: 'renamed-existing', + }))) + const reopened = expectOk(await api.workspace.create(request({ path: existing }))) + expect(reopened.workspace.title).toBe('renamed-existing') + const missing = join(workspaceRoot, 'missing') const missingResult = await api.workspace.create(request({ path: missing })) expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) @@ -141,6 +172,20 @@ describe('workspace.create', () => { expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) } }) + + it('rejects different paths that derive the same Workspace title', async () => { + const { api, workspaceRoot } = await harness() + const first = join(workspaceRoot, 'one', 'project') + const second = join(workspaceRoot, 'two', 'project') + mkdirSync(first, { recursive: true }) + mkdirSync(second, { recursive: true }) + expectOk(await api.workspace.create(request({ path: first }))) + const conflict = await api.workspace.create(request({ path: second })) + expect(conflict.result).toMatchObject({ + ok: false, + error: { code: 'workspace-name-conflict', details: { name: 'project' } }, + }) + }) }) describe('session creation and Workspace membership', () => { diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 38ad7a52c9..2cf82f75b2 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -35,7 +35,11 @@ function scriptedApi(overrides: { cancel: r => ok(r, { accepted: true as const }), ...overrides.sessions, }, - host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), ...overrides.host }, + host: { + describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), + pickDirectory: r => ok(r, { path: null }), + ...overrides.host, + }, workspace: { list: r => ok(r, { items: [] }), create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 3978b2cfe4..f90fd72e8a 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -47,6 +47,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async describe(request) { return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } } }, + async pickDirectory(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } } + }, }, workspace: { async list(request) { @@ -108,8 +111,8 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra } } -function client(api: ApiProxy = fakeApi()): InProcessApiClient { - return new InProcessApiClient(toFetchHandler(api)) +function client(api: ApiProxy = fakeApi(), timeoutMs?: number): InProcessApiClient { + return new InProcessApiClient(toFetchHandler(api), timeoutMs) } async function collect(stream: AsyncIterable>): Promise[]> { @@ -145,6 +148,16 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect((await c.host.describe({})).result.ok).toBe(true) }) + it('round-trips the native picker without the default unary timeout', async () => { + const api = fakeApi() + api.host.pickDirectory = async (request) => { + await new Promise(resolve => setTimeout(resolve, 15)) + return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/project' } } } + } + const response = await client(api, 1).host.pickDirectory({}) + expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } }) + }) + it('round-trips command.list / command.execute / skill.list through the wire form', async () => { const c = client() const list = await c.commands.list({ sessionId: 's' as never }) @@ -170,6 +183,30 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(parsed.rpcId).toBe('r-sig') expect(parsed.result.error?.code).toBe('cancelled') }) + + it('propagates the carrier Request signal into host.pickDirectory', async () => { + const api = fakeApi() + api.host.pickDirectory = async (request, signal) => { + if (!signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + return { + rpcId: request.rpcId, + result: { ok: false, error: { code: 'cancelled', message: 'aborted', details: {} } }, + } + } + const handler = toFetchHandler(api) + const controller = new AbortController() + const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} }) + const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', { + method: 'POST', body, signal: controller.signal, + })) + controller.abort() + const parsed = await (await pending).json() as { result: { error?: { code: string } } } + expect(parsed.result.error?.code).toBe('cancelled') + }) }) describe('handler carrier-layer statuses', () => { diff --git a/packages/host/apiproxy/tests/native-directory-picker.spec.ts b/packages/host/apiproxy/tests/native-directory-picker.spec.ts new file mode 100644 index 0000000000..c33b68a236 --- /dev/null +++ b/packages/host/apiproxy/tests/native-directory-picker.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' +import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-directory-picker.ts' + +function failure(code: string | number, stderr = ''): Error { + return Object.assign(new Error(`command failed: ${String(code)}`), { code, stderr }) +} + +const signal = () => new AbortController().signal + +describe('native directory picker', () => { + it('uses the macOS folder chooser and maps user cancellation to null', async () => { + const run = vi.fn(async () => ({ stdout: '/Users/test/project/\n', stderr: '' })) + await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBe('/Users/test/project/') + expect(run).toHaveBeenCalledWith('osascript', expect.arrayContaining(['POSIX path of selectedFolder']), expect.any(AbortSignal)) + + run.mockRejectedValueOnce(failure(1, 'execution error: User canceled. (-128)')) + await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBeNull() + }) + + it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => { + const run = vi.fn(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' })) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\project') + expect(run).toHaveBeenCalledWith( + 'powershell.exe', + expect.arrayContaining(['-NoProfile', '-STA', '-Command']), + expect.any(AbortSignal), + ) + expect(run.mock.calls[0]?.[1].at(-1)).toContain("$ErrorActionPreference = 'Stop'") + run.mockResolvedValueOnce({ stdout: '', stderr: '' }) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull() + run.mockRejectedValueOnce(failure(1, 'Add-Type failed')) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed') + }) + + it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => { + const run = vi.fn() + .mockRejectedValueOnce(failure('ENOENT')) + .mockResolvedValueOnce({ stdout: '/home/test/project\n', stderr: '' }) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBe('/home/test/project') + expect(run.mock.calls.map(call => call[0])).toEqual(['zenity', 'kdialog']) + }) + + it('maps Linux cancellation to null and reports a missing desktop picker', async () => { + const cancelled = vi.fn(async () => { throw failure(1) }) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: cancelled })).resolves.toBeNull() + + const missing = vi.fn(async () => { throw failure('ENOENT') }) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: missing })) + .rejects.toThrow('install zenity or kdialog') + }) + + it('does not convert caller aborts into user cancellation', async () => { + const abort = new AbortController() + abort.abort(new Error('closed')) + const run = vi.fn(async () => { throw failure('ABORT_ERR') }) + await expect(pickNativeDirectory(abort.signal, { platform: 'linux', run })).rejects.toThrow('command failed') + }) +}) From 9dd57a25c58d93c7020e774176e925129f7e8850 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 19:20:33 +0800 Subject: [PATCH 62/70] fix(ui-question): stop option rows absorbing the capped card's shortfall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question composer card is capped against the viewport and scrolls its option list. `.options` is a flex column whose children defaulted to `flex-shrink: 1`, so a short seat shrank the rows before overflowing the scroll container: a row collapsed to its 42px minimum while `.optionCopy` kept the taller height its wrapped copy needs, and `align-items: center` then painted that copy outside the row's border box — over the question title above and the next row below. Measured 6.5px of spill at 900x440 on the shipped client, 10px at 380px tall, with `.options` reporting scrollHeight === clientHeight and therefore offering no scrollbar. `.option` and `.custom` now declare `flex-shrink: 0`, so the shortfall reaches the scroll container that already owns `overflow-y: auto` — the behavior the cap was designed for. Only rows whose copy wraps could reproduce this, which is why the recorded scenario now asks a question with long option descriptions; the web e2e asserts at three squeezed seat heights that every row's children stay inside its border box, guarded against holding vacuously by requiring a wrapped row and a scrolling list. --- ...tion-composer-rows-do-not-shrink.i18n.yaml | 6 ++ ...27-question-composer-rows-do-not-shrink.md | 46 ++++++++++++++ ...question-composer-rows-do-not-shrink.zh.md | 46 ++++++++++++++ apps/web/tests/question-composer.e2e.ts | 47 +++++++++++++- .../question-composer/answered.expected.md | 14 ++--- .../snapshots/question-composer/session.jsonl | 62 +++++++++---------- .../question-composer/ui.expected.md | 4 +- .../src/client/QuestionComposer.module.css | 8 +++ 8 files changed, 192 insertions(+), 41 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml new file mode 100644 index 0000000000..0a1e35ea19 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml @@ -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/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md +2026-07-27-question-composer-rows-do-not-shrink.md: 519baa831b05cd41907edb6441c65c104a571a9e +2026-07-27-question-composer-rows-do-not-shrink.zh.md: f4d24bd7086735cac795223f22d49db81bf41fde diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md new file mode 100644 index 0000000000..519baa831b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md @@ -0,0 +1,46 @@ +# Agent Note: Question-composer option rows are scroll content, not the slack absorber + +Status: implemented + +English | [中文](2026-07-27-question-composer-rows-do-not-shrink.zh.md) + +## Problem + +The question composer card is capped against the viewport (`max-height: min(60vh, 520px)`) and scrolls its option list, so the header and the footer actions stay reachable on long question batches. When the composer seat got short — a small window, or a short viewport with the details panel open — the option rows rendered on top of each other and on top of the question title. + +The cap was not the defect; the distribution of the shortfall was. `.options` is a `flex-direction: column` box whose children default to `flex-shrink: 1`, so under-allocation shrank the rows first instead of overflowing the scroll container. A row shrank to its `min-height: 42px` while `.optionCopy` kept the taller intrinsic height its wrapped copy needs (two lines for an option with a description). With `align-items: center`, the copy is then centered on a box shorter than itself and paints outside the row's border box in both directions — over the title above and the next row below. Measured on the shipped client at 900x440: 6.5px of copy outside the row box, growing to 10px at 380px tall, while `.options` reported `scrollHeight === clientHeight` and therefore never offered a scrollbar. + +Only rows whose copy wraps can reproduce it. A row whose copy fits on one line has slack between its content and its 42px minimum, so shrinking it stays invisible — which is why the pre-existing e2e fixture (options `Blue`/`Green`, no descriptions) rendered correctly at every size. + +## Decision + +`.option` and `.custom` declare `flex-shrink: 0`. + +The rows are the scroll content of a capped card; the card's overflow belongs to `.options`, which already owns `overflow-y: auto` and `min-height: 0`. Pinning the children makes the shortfall reach that scroll container instead of being absorbed by the rows, which is the behavior the cap was designed for. The alternative — letting rows shrink but keeping the copy inside them — would require clipping or ellipsizing option descriptions at exactly the sizes where the user most needs to read them. + +`.header` and `.footer` already carried `flex-shrink: 0` for the same reason at the card level; the option list's children were the missing half of that rule. + +## Alternatives considered + +**Clip or ellipsize the copy inside a shrunk row (`overflow: hidden` on `.option`).** This removes the overlap with one declaration and no layout rethink. Rejected because it trades a visible defect for a silent one: the row keeps its 42px, and the second line of an option description simply disappears at exactly the sizes where the card is tightest. The description is decision-relevant content, not decoration. + +**Drop `align-items: center` for `align-items: flex-start`.** The copy would grow downward only, so it would no longer paint over the title above. It does not fix anything: a shrunk row still overflows onto the row below, and the fix would silently change the vertical alignment of every option row at every size, including the common one. + +**Remove the card's `max-height` cap so nothing is ever squeezed.** No shortfall means no distribution problem. Rejected because the cap is what keeps the header and the footer actions on screen for a long question batch; removing it reintroduces the failure the cap exists to prevent (the composer seat is a fixed-height conversation column with `overflow: hidden`, so an uncapped card loses its own submit button instead). + +**Cap the wrapped copy at one line (`white-space: nowrap` plus ellipsis on `.description`).** Rows would never wrap, so they could never overflow when shrunk. Rejected for the same reason as clipping, plus it degrades the wide-viewport rendering — where there is ample room — to fix a narrow-viewport defect. + +## Consequences + +- A squeezed composer scrolls its option list instead of overlapping it: at 900x380 the list reports `scrollHeight` 200 against `clientHeight` 114 and offers a scrollbar, where before it reported them equal and offered none. +- Option rows keep their full wrapped copy at every viewport size. Nothing is clipped or ellipsized, and the wide-viewport rendering is unchanged (the rule only takes effect when the flex box is under-allocated). +- The card now reaches its scroll state sooner, since the shortfall is no longer partly absorbed by the rows. That is the intended behavior of the cap, and it means a short seat shows a scrollbar in cases that previously showed a silently mis-painted list. +- The scenario's recorded question is longer than it needs to be for the round trip it primarily tests. That cost is deliberate: the layout invariant is unfalsifiable without wrapping copy, and a second fixture for one CSS rule would be worse. + +## Verification + +The web e2e composer scenario asserts the invariant on the live composer at three squeezed seat heights (900x520 / 440 / 380): every option row's children stay inside the row's border box. Two guards keep the assertion from holding vacuously — at least one row must be wrapped (the only shape that overflows) and `.options` must actually be scrolling (proof the seat is genuinely capped). The scenario's recorded question now carries long option descriptions for exactly that reason; without wrapping copy the assertion cannot fail. + +Confirmed both directions against the built client: with `flex-shrink: 0` reverted the scenario fails (`scrolls: false`, 6.5px spill), and with it restored it passes. A standalone geometry sweep over 340 viewport sizes (420-1600 x 320-960) went from 86 sizes with copy outside a row box to zero. + +The assertion is replay-only: record mode must reach the fixture write rather than aborting on layout. Note that the composer ships as a client-module bundle, so `pnpm run build:web` alone does not pick up a change to `QuestionComposer.module.css` — the package build must run for the browser lane to see it. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md new file mode 100644 index 0000000000..f4d24bd708 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md @@ -0,0 +1,46 @@ +# Agent Note: Question-composer option rows are scroll content, not the slack absorber + +Status: implemented + +[English](2026-07-27-question-composer-rows-do-not-shrink.md) | 中文 + +## Problem + +提问 composer 的卡片会按视口设上限(`max-height: min(60vh, 520px)`),并让选项列表自行滚动,这样在成批提问时标题和底部操作按钮始终可达。但当 composer 的容器变矮时(窗口较小,或视口偏矮且详情面板处于展开状态),选项行会互相叠在一起,也会叠到问题标题上。 + +缺陷不在这个高度上限,而在高度不足时由谁来吸收。`.options` 是一个 `flex-direction: column` 的盒子,其子元素默认取 `flex-shrink: 1`,因此空间不足时首先被压缩的是各个选项行,而不是让滚动容器产生溢出。一行会被压到它的 `min-height: 42px`,而 `.optionCopy` 仍保持文案折行后所需的更大固有高度(带描述的选项会占两行)。由于 `align-items: center`,文案于是以一个比自身更矮的盒子为基准居中,并向上下两个方向画到该行边框盒之外——向上盖住标题,向下盖住下一行。在实际发布的客户端上于 900x440 处实测:文案有 6.5px 落在行盒之外,视口高度降到 380px 时增至 10px,而 `.options` 报告的 `scrollHeight` 等于 `clientHeight`,因此始终不会给出滚动条。 + +只有文案会折行的选项行才能复现该问题。文案单行即可容纳的行,其内容与 42px 最小高度之间尚有余量,被压缩也看不出来——这正是既有 e2e fixture(测试前置数据)(选项为 `Blue`/`Green`,无描述)在任何尺寸下都渲染正常的原因。 + +## Decision + +`.option` 与 `.custom` 声明 `flex-shrink: 0`。 + +在设有高度上限的卡片中,这些行是滚动内容;卡片的溢出归 `.options` 承担,它本就持有 `overflow-y: auto` 与 `min-height: 0`。把子元素固定住之后,高度不足会传导到那个滚动容器,而不再被行本身吸收,这正是该高度上限设计时想要的行为。另一种做法是允许行被压缩,但把文案约束在行内,那就必须在用户最需要阅读选项描述的尺寸上对其做裁剪或省略号处理。 + +`.header` 与 `.footer` 出于同样的原因,已在卡片层级带有 `flex-shrink: 0`;选项列表的子元素正是这条规则缺失的另一半。 + +## Alternatives considered + +**在被压缩的行内裁剪文案或加省略号(对 `.option` 设 `overflow: hidden`)。** 这样一条声明就能消除重叠,且不必重新考虑布局。之所以否决:它把一个可见缺陷换成了一个无声缺陷——行仍保持 42px,而选项描述的第二行会在卡片最紧张的那些尺寸上直接消失。描述属于影响决策的内容,不是装饰。 + +**把 `align-items: center` 改为 `align-items: flex-start`。** 文案就只会向下生长,因此不再向上盖住标题。但这什么也没修好:被压缩的行依然会溢出到下一行上,而且这一改动会在所有尺寸下(包括常见尺寸)无声改变每个选项行的垂直对齐。 + +**移除卡片的 `max-height` 上限,使其永远不会被压缩。** 没有高度不足,就没有分配问题。之所以否决:正是这个上限保证成批提问时标题和底部操作按钮留在屏幕内;移除它会重新引入该上限本就为之存在的失败(composer 所处的容器是一个固定高度、`overflow: hidden` 的会话列,因此不设上限的卡片会连自己的提交按钮一起丢掉)。 + +**把折行文案限制为单行(对 `.description` 设 `white-space: nowrap` 加省略号)。** 行永远不会折行,因此被压缩时也永远不会溢出。否决理由与裁剪相同,此外它还为了修一个窄视口缺陷,而牺牲了空间充裕的宽视口渲染效果。 + +## Consequences + +- 被压缩的 composer 会滚动其选项列表,而不是让它互相重叠:在 900x380 处,该列表报告 `scrollHeight` 为 200、`clientHeight` 为 114,并给出滚动条;此前两者相等,不给滚动条。 +- 选项行在任何视口尺寸下都保留完整的折行文案。不裁剪、不加省略号,宽视口下的渲染保持不变(该规则仅在 flex 盒子空间不足时才生效)。 +- 由于高度不足不再被行部分吸收,卡片现在更早进入滚动状态。这正是该高度上限想要的行为,也意味着在此前只会无声画错列表的情形下,矮容器现在会显示滚动条。 +- 该场景录制的问题,比它主要测试的那次往返所需的长度更长。这个代价是有意付出的:没有折行文案,该布局不变式无法被证伪,而为一条 CSS 规则再加一份 fixture 会更糟。 + +## Verification + +Web e2e 的 composer 场景会在三个被压缩的容器高度(900x520/440/380)上,对活动的 composer 断言该运行时不变式:每个选项行的子元素都留在该行的边框盒之内。两道守卫防止该断言空洞地成立——必须至少有一行处于折行状态(这是唯一会溢出的形态),且 `.options` 必须确实处在滚动状态(证明容器确实触及了高度上限)。该场景录制的问题现在带有较长的选项描述,正是为此;没有折行文案,这条断言不可能失败。 + +在构建产物客户端上双向确认过:撤销 `flex-shrink: 0` 后该场景失败(`scrolls: false`,6.5px 溢出),恢复后通过。一次覆盖 340 种视口尺寸(420-1600 x 320-960)的独立几何遍历,从 86 种尺寸存在文案落在行盒之外,降到 0 种。 + +该断言仅在回放模式下执行:录制模式必须走到写入 fixture 那一步,而不是在布局检查处中断。另需注意,composer 以客户端模组包的形式发布,因此单跑 `pnpm run build:web` 不会带上对 `QuestionComposer.module.css` 的改动——必须执行包构建,浏览器测试通道才能看到它。 diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 46f6af7b86..6ecdb683b3 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -28,7 +28,10 @@ const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md') const MODE = webSnapshotMode() -const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and options labeled "Blue" and "Green". After I answer, reply with the single word DONE and stop.' +// The options carry long descriptions on purpose: the squeeze assertion below +// needs option copy that WRAPS, which is the only shape that reproduces a +// collapsed row painting its copy outside its own box. +const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." After I answer, reply with the single word DONE and stop.' describe('web e2e: resident question composer round trip', () => { let scaffold: WebScaffold @@ -79,6 +82,48 @@ describe('web e2e: resident question composer round trip', () => { await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) } + // Squeezed card: the option rows are the capped card's scroll content, so + // shrinking the seat must push overflow into the option list, never + // collapse a row below the height its own copy needs — a collapsed row + // paints its centered copy outside the row box, over the title and the + // neighbouring rows. Measured on the live composer at seat heights that + // force the cap, then restored for the answer gesture below. Replay only: + // record mode must reach the recording write below, not abort on layout. + if (MODE !== 'record') { + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + for (const height of [520, 440, 380]) { + await page.setViewportSize({ width: 900, height }) + const squeeze = await composer.evaluate((card) => { + // Role/ARIA selectors, not the CSS-module class names: the built + // client hashes those. + const rows = [...card.querySelectorAll( + '[role="radio"], [role="checkbox"], [aria-expanded]', + )] + const spill = rows.map(row => Math.max(...[...row.children].map((child) => { + const box = row.getBoundingClientRect() + const inner = child.getBoundingClientRect() + return Math.max(box.top - inner.top, inner.bottom - box.bottom) + }))) + const list = rows[0]?.parentElement ?? null + return { + rows: rows.length, + spill: Math.max(...spill), + // Wrapped copy is the shape that overflows a collapsed row, and a + // scrolling list proves the seat is genuinely capped. Without both, + // the spill assertion would hold vacuously. + wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length, + scrolls: list === null ? false : list.scrollHeight > list.clientHeight, + } + }) + expect(squeeze.rows).toBeGreaterThan(0) + expect(squeeze.wrappedRows).toBeGreaterThan(0) + expect(squeeze.scrolls).toBe(true) + // Sub-pixel tolerance: every row's copy stays inside its border box. + expect(squeeze.spill).toBeLessThan(0.6) + } + await page.setViewportSize(original) + } + await composer.getByRole('radio', { name: 'Blue' }).click() // Submit: Enter on the focused option (the composer's documented submit). await composer.getByRole('radio', { name: 'Blue' }).press('Enter') diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index be5b958bf2..0ac91eed92 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -6,18 +6,18 @@ - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" -- text: Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and options labeled "Blue" and "Green". After I answer, reply with the single word DONE and stop. -- button "Think The user wants me to use the ask_user_question tool to ask a specific question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". Let me do exactly that.": +- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop." +- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img - - text: Think The user wants me to use the ask_user_question tool to ask a specific question with id "color", question "Which color do you prefer?", header "Pick one", and options labeled "Blue" and "Green". Let me do exactly that. + - text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that. - button: - img -- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}" -- button "Think The user answered \"Blue\". I need to reply with the single word DONE and stop.": +- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}" +- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.": - img - - text: Think The user answered "Blue". I need to reply with the single word DONE and stop. + - text: Think The user answered "Blue". I should now reply with the single word DONE and stop. - paragraph: DONE -- text: cache hit 99% · 15,978 tokens · 1 turns · 2 steps +- text: cache hit 95% · 8,769 tokens · 1 turns · 2 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/question-composer/session.jsonl b/apps/web/tests/snapshots/question-composer/session.jsonl index 43cc228253..a98f2a92f2 100644 --- a/apps/web/tests/snapshots/question-composer/session.jsonl +++ b/apps/web/tests/snapshots/question-composer/session.jsonl @@ -1,31 +1,31 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785001700711,"cwd":"{{cwd}}/workspace"} -{"type":"turn/start","seq":0,"time":1785001700724,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1785001700725,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":1785001700727,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":1785001700783,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1785001700784,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":1785001701372,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":6,"time0":1785001701373,"data":{"turn":1,"step":1,"index":0,"dt":[117,23,0,0,0,1,26,1,0,0,0,0,25,0,0,0,27,1,24,1,0,0,0,27,0,0,0,0,1,34,0,0,0,0,1,17,0,0,0,1,0,27,1,0,0,28,0,0,22,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," to"," ask"," a"," specific"," question"," with"," id"," \"","color","\","," question"," \"","Which"," color"," do"," you"," prefer","?\","," header"," \"","Pick"," one","\","," and"," options"," labeled"," \"","Blue","\""," and"," \"","Green","\"."," Let"," me"," do"," exactly"," that","."]}} -{"type":"assistant/chunk","seq":57,"time":1785001701858,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":58,"time0":1785001701858,"data":{"turn":1,"step":1,"index":1,"dt":[27,1,0,0,0,24,1,0,0,28,0,0,0,0,1,24,0,0,1,0,0,26,0,0,0,0,0,26,0,1,0,0,0,25,0,0,0,0,3,23,1,0,0,0,0,26,1,26],"id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\"},"," {\"","label","\":"," \"","Green","\"","}]","}]","}"]}} -{"type":"assistant/chunk","seq":107,"time":1785001702154,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask a specific question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". Let me do exactly that."}}}} -{"type":"assistant/chunk","seq":108,"time":1785001702155,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"}}}} -{"type":"assistant/chunk","seq":109,"time":1785001702155,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":23,"outputTokens":138,"cacheReadTokens":7808,"reasoningTokens":51}}}} -{"type":"assistant/chunk","seq":110,"time":1785001702155,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":111,"time":1785001702159,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool to ask a specific question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and options labeled \"Blue\" and \"Green\". Let me do exactly that."},{"type":"tool-call","id":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":23,"outputTokens":138,"cacheReadTokens":7808,"reasoningTokens":51}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110],"surfaceOp":"append"} -{"type":"tool/call","seq":112,"time":1785001702160,"data":{"turn":1,"step":1,"callId":"call_00_evaSJ80aahxJCcpWrfA00887","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\"}, {\"label\": \"Green\"}]}]}"}} -{"type":"tool/result","seq":113,"time":1785001702566,"data":{"turn":1,"step":1,"callId":"call_00_evaSJ80aahxJCcpWrfA00887","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[112],"surfaceOp":"append"} -{"type":"step/end","seq":114,"time":1785001702568,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":115,"time":1785001702569,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":116,"time":1785001702948,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":117,"time0":1785001702949,"data":{"turn":1,"step":2,"index":0,"dt":[84,26,1,0,0,0,0,29,0,0,22,0,1,0,0,0,27,1],"texts":["The"," user"," answered"," \"","Blue","\"."," I"," need"," to"," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}} -{"type":"assistant/chunk","seq":136,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":137,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} -{"type":"assistant/chunk","seq":138,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} -{"type":"assistant/chunk","seq":139,"time":1785001703140,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user answered \"Blue\". I need to reply with the single word DONE and stop."}}}} -{"type":"assistant/chunk","seq":140,"time":1785001703141,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":141,"time":1785001703141,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":179,"outputTokens":22,"cacheReadTokens":7808,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":142,"time":1785001703141,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":143,"time":1785001703141,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user answered \"Blue\". I need to reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":179,"outputTokens":22,"cacheReadTokens":7808,"reasoningTokens":19}},"sourceEventSeqs":[116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142],"surfaceOp":"append"} -{"type":"step/end","seq":144,"time":1785001703142,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":145,"time":1785001703142,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"} +{"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785150168452,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785150168452,"data":{"turn":1,"step":1,"index":0,"dt":[87,26,1,0,0,0,38,0,0,0,0,1,12,27,0,27,0,0,1,25,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}} +{"type":"assistant/chunk","seq":28,"time":1785150168775,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} +{"type":"assistant/chunk","seq":127,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."}}}} +{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}} +{"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} +{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}} +{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} +{"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":137,"time0":1785150170606,"data":{"turn":1,"step":2,"index":0,"dt":[111,29,0,0,0,1,34,0,0,17,1,29,0,0,0,0,1,26],"texts":["The"," user"," answered"," \"","Blue","\"."," I"," should"," now"," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}} +{"type":"assistant/chunk","seq":156,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":157,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":158,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":159,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user answered \"Blue\". I should now reply with the single word DONE and stop."}}}} +{"type":"assistant/chunk","seq":160,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":161,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":162,"time":1785150170856,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":163,"time":1785150170857,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user answered \"Blue\". I should now reply with the single word DONE and stop."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":284,"outputTokens":22,"cacheReadTokens":4096,"reasoningTokens":19}},"sourceEventSeqs":[136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162],"surfaceOp":"append"} +{"type":"step/end","seq":164,"time":1785150170858,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":165,"time":1785150170858,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/question-composer/ui.expected.md b/apps/web/tests/snapshots/question-composer/ui.expected.md index 3368b373a0..934ec244d5 100644 --- a/apps/web/tests/snapshots/question-composer/ui.expected.md +++ b/apps/web/tests/snapshots/question-composer/ui.expected.md @@ -10,10 +10,10 @@ - img - radiogroup: - radio "Blue": - - text: 1 Blue + - text: 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. - img - radio "Green": - - text: 2 Green + - text: 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - img - button "其他,请填写自定义答案": - img diff --git a/packages/client/ui-question/src/client/QuestionComposer.module.css b/packages/client/ui-question/src/client/QuestionComposer.module.css index 6c0c1b854c..19306bd552 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.module.css +++ b/packages/client/ui-question/src/client/QuestionComposer.module.css @@ -128,6 +128,11 @@ gap: 10px; width: 100%; min-height: 42px; + /* Rows are the scroll content, never the slack absorber: a shrinkable row + collapses to min-height while its wrapped copy keeps the taller + intrinsic height, and centered content then paints outside the row box — + over the title and the next row. Overflow belongs to .options. */ + flex-shrink: 0; padding: 5px 8px; border: 1px solid transparent; border-radius: 12px; @@ -208,6 +213,9 @@ } .custom { + /* Same reason as .option: the custom block is scroll content, and shrinking + it pushes its trigger row (and the open textarea) past the footer. */ + flex-shrink: 0; border: 1px solid transparent; border-radius: 12px; } From a5239b0f64dcdc46b30d0cfed1bbb27a39864f84 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:37:24 -0700 Subject: [PATCH 63/70] test(gui): cover native workspace picker branches --- .../tests/native-dialog-request.spec.ts | 21 ++++- .../src/client/WorkspacePicker.tsx | 2 - .../tests/workspace-picker.spec.tsx | 9 +++ .../tests/native-directory-picker.spec.ts | 81 +++++++++++++++++++ 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/packages/client/connection/tests/native-dialog-request.spec.ts b/packages/client/connection/tests/native-dialog-request.spec.ts index 8048f6da83..1a3d70dd15 100644 --- a/packages/client/connection/tests/native-dialog-request.spec.ts +++ b/packages/client/connection/tests/native-dialog-request.spec.ts @@ -1,9 +1,10 @@ +import type { IncomingHttpHeaders } from 'node:http' import { describe, expect, it } from 'vitest' import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts' function request( - remoteAddress: string, - headers: Record = { + remoteAddress: string | undefined, + headers: IncomingHttpHeaders = { host: '127.0.0.1:3080', origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', @@ -22,10 +23,14 @@ describe('native dialog request trust', () => { expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', }))).toBe(true) + expect(isTrustedNativeDialogRequest(request('127.0.0.2', { + host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(true) }) it('rejects remote sockets and requests without matching browser metadata', () => { expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false) + expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false) expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site', }))).toBe(false) @@ -33,8 +38,20 @@ describe('native dialog request trust', () => { host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', }))).toBe(false) expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin', }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin', + }))).toBe(false) }) }) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 6b173b8987..dd0cf6f562 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -88,7 +88,6 @@ export function WorkspaceCreateFlow({ } const openLocalFolder = (): void => { - if (pickingFolder) return onClose() setModalKind(null) setModalError(null) @@ -109,7 +108,6 @@ export function WorkspaceCreateFlow({ } const handleSelect = (id: string): void => { - if (pickingFolder) return if (id === OPEN_LOCAL_FOLDER) { openLocalFolder() return diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 99c5e71b48..04a320139f 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -138,6 +138,15 @@ describe('WorkspacePicker', () => { await act(async () => { resolve(null); await pending }) }) + it('reports non-Error native picker failures', async () => { + const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' })) + chooseItem('Open local folder…') + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toBe('picker unavailable') + }) + expect(b.createWorkspace).not.toHaveBeenCalled() + }) + it('closes a creation modal when the user cancels', () => { mount([]) chooseItem('Create a new workspace') diff --git a/packages/host/apiproxy/tests/native-directory-picker.spec.ts b/packages/host/apiproxy/tests/native-directory-picker.spec.ts index c33b68a236..783a67a04b 100644 --- a/packages/host/apiproxy/tests/native-directory-picker.spec.ts +++ b/packages/host/apiproxy/tests/native-directory-picker.spec.ts @@ -1,3 +1,19 @@ +type ExecFileCallback = ( + error: (Error & { code?: string | number }) | null, + stdout: string, + stderr: string, +) => void +type ExecFileMock = ( + command: string, + args: readonly string[], + options: { encoding: string; signal: AbortSignal; windowsHide: boolean }, + callback: ExecFileCallback, +) => void + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) + +vi.mock('node:child_process', () => ({ execFile: execFileMock })) + import { describe, expect, it, vi } from 'vitest' import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-directory-picker.ts' @@ -15,6 +31,19 @@ describe('native directory picker', () => { run.mockRejectedValueOnce(failure(1, 'execution error: User canceled. (-128)')) await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBeNull() + + run.mockRejectedValueOnce(failure(2, 'permission denied')) + await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toThrow('command failed') + }) + + it.each([ + ['a primitive error', 'failed'], + ['an invalid code type', { code: true }], + ['a missing stderr property', { code: 1 }], + ['a non-string stderr property', { code: 1, stderr: 42 }], + ])('does not mistake %s for macOS cancellation', async (_label, reason) => { + const run = vi.fn(async () => { throw reason }) + await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason) }) it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => { @@ -32,12 +61,44 @@ describe('native directory picker', () => { await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed') }) + it('runs the default command adapter without a shell and preserves command failures', async () => { + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(null, 'C:\\work\\default\r\n', '') + }) + await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default') + const [command, args, options] = execFileMock.mock.calls[0]! + expect(command).toBe('powershell.exe') + expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command'])) + expect(options.encoding).toBe('utf8') + expect(options.windowsHide).toBe(true) + expect(options.signal).toBeInstanceOf(AbortSignal) + + const commandError = Object.assign(new Error('powershell failed'), { code: 7 }) + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(commandError, 'partial output', 'failure details') + }) + await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({ + message: 'powershell failed', cause: commandError, code: 7, + stdout: 'partial output', stderr: 'failure details', + }) + }) + + it('uses the current process platform when no platform override is supplied', async () => { + const run = vi.fn(async () => ({ stdout: '/default/platform\n', stderr: '' })) + await expect(pickNativeDirectory(signal(), { run })).resolves.toBe('/default/platform') + }) + it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => { const run = vi.fn() .mockRejectedValueOnce(failure('ENOENT')) .mockResolvedValueOnce({ stdout: '/home/test/project\n', stderr: '' }) await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBe('/home/test/project') expect(run.mock.calls.map(call => call[0])).toEqual(['zenity', 'kdialog']) + + const zenity = vi.fn(async () => ({ stdout: '/home/test/direct\n', stderr: '' })) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenity })) + .resolves.toBe('/home/test/direct') + expect(zenity).toHaveBeenCalledOnce() }) it('maps Linux cancellation to null and reports a missing desktop picker', async () => { @@ -47,6 +108,22 @@ describe('native directory picker', () => { const missing = vi.fn(async () => { throw failure('ENOENT') }) await expect(pickNativeDirectory(signal(), { platform: 'linux', run: missing })) .rejects.toThrow('install zenity or kdialog') + + const kdialogCancelled = vi.fn() + .mockRejectedValueOnce(failure('ENOENT')) + .mockRejectedValueOnce(failure(1)) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogCancelled })) + .resolves.toBeNull() + + const zenityFailed = vi.fn(async () => { throw failure(2) }) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenityFailed })) + .rejects.toThrow('command failed') + + const kdialogFailed = vi.fn() + .mockRejectedValueOnce(failure('ENOENT')) + .mockRejectedValueOnce(failure(2)) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogFailed })) + .rejects.toThrow('command failed') }) it('does not convert caller aborts into user cancellation', async () => { @@ -55,4 +132,8 @@ describe('native directory picker', () => { const run = vi.fn(async () => { throw failure('ABORT_ERR') }) await expect(pickNativeDirectory(abort.signal, { platform: 'linux', run })).rejects.toThrow('command failed') }) + + it('reports unsupported platforms', async () => { + await expect(pickNativeDirectory(signal(), { platform: 'aix' })).rejects.toThrow('unsupported on aix') + }) }) From a72436105b40830826dc9fe2ee000842e3aab558 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 27 Jul 2026 19:49:19 +0800 Subject: [PATCH 64/70] docs(telemetry): state the crash-repair export semantics for resumed streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: synthetic turn closers written by SessionPersistence .load() at crash-resume sit below firstLiveSeq and are never exported, leaving the remote turn unbalanced. Kept deliberately — exporting a synthetic closer cannot complete a turn whose real tail records died in the crashed process's queue; it can only make an incomplete turn look closed. The OTel README now states the receiver rule (a never-closed turn on a resumed stream marks the previous process dying inside it; a later clean shutdown marker attests only to the resumed process's exit), and the revival Agent Note records why the repair suffix stays local. --- .../2026-07-23-session-telemetry-otel-revival.i18n.yaml | 4 ++-- .../feature/2026-07-23-session-telemetry-otel-revival.md | 2 +- .../feature/2026-07-23-session-telemetry-otel-revival.zh.md | 2 +- packages/telemetry/session-telemetry-otel/README.i18n.yaml | 4 ++-- packages/telemetry/session-telemetry-otel/README.md | 2 +- packages/telemetry/session-telemetry-otel/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index 9accad167a..8c4c025c86 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -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 -2026-07-23-session-telemetry-otel-revival.md: f790bafe07caa6da00621f395de62909be9ee238 -2026-07-23-session-telemetry-otel-revival.zh.md: 9b2cf657541cc08ba238c5b5d952f8908e825537 +2026-07-23-session-telemetry-otel-revival.md: 1c855ad8d3263f5ff3343b480983f7cf14d9d4c6 +2026-07-23-session-telemetry-otel-revival.zh.md: 371e8f9fbfbdcc7c74fa5ae39406ea4bf159bc81 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index f790bafe07..1c855ad8d3 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -28,7 +28,7 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry **Map onto OTel spans (GenAI semantic conventions) instead of logs.** Rejected for this revival: the branch implementation's log mapping is reviewed and shipped-shaped; the span model is lossy for forkable, interruptible sessions and belongs to a future consumer with real span queries to serve. -**Full-log replay when no handoff cursor survived (re-export constructor seeds).** Shipped in the first revival round, then narrowed: adoption now replays from the session's construction boundary (`Session.firstLiveSeq`, the constructor-seed length — a fact the session already validated but did not expose; `header.seedLength` cannot serve, it is the durable fork-lineage value and a resumed session's constructor seed is its full stored log). A resumed session's history already shipped from the previous process under the same id, and a fork's inherited prefix already shipped in the parent's stream — re-exporting either re-billed every resume for its full history and doubled query-time counts on OTLP backends with no native ingest dedupe. Receivers stitch fork lineage via `session.parent_id` + `session.seed_length`. What the narrowing gives up, consistently with the at-most-once stance: a resume no longer backfills records the previous process failed to deliver (telemetry unmounted then, or queued at crash) — the full replay's only real benefit, bought at the common case's expense. A deployment that states a backfill requirement needs the deferred outbox above, not replay. +**Full-log replay when no handoff cursor survived (re-export constructor seeds).** Shipped in the first revival round, then narrowed: adoption now replays from the session's construction boundary (`Session.firstLiveSeq`, the constructor-seed length — a fact the session already validated but did not expose; `header.seedLength` cannot serve, it is the durable fork-lineage value and a resumed session's constructor seed is its full stored log). A resumed session's history already shipped from the previous process under the same id, and a fork's inherited prefix already shipped in the parent's stream — re-exporting either re-billed every resume for its full history and doubled query-time counts on OTLP backends with no native ingest dedupe. Receivers stitch fork lineage via `session.parent_id` + `session.seed_length`. What the narrowing gives up, consistently with the at-most-once stance: a resume no longer backfills records the previous process failed to deliver (telemetry unmounted then, or queued at crash) — the full replay's only real benefit, bought at the common case's expense. A deployment that states a backfill requirement needs the deferred outbox above, not replay. The boundary also swallows the synthetic turn closers `SessionPersistence.load()` writes when repairing a crash-interrupted log (they sit below `firstLiveSeq` despite never existing in the previous process) — deliberate, not incidental: exporting a synthetic closer cannot complete the remote turn whose real tail records died in the crashed process's queue, it can only make an incomplete turn look closed. The wire stream stays faithful to what the crashed process actually shipped; receivers read a never-closed turn on a resumed stream as "the previous process died inside it" (the OTel README states the rule), and a later clean `shutdown` marker attests only to the resumed process's exit. Threading the pre-repair boundary through load/prepare so repairs export as live events would couple three packages to un-ship that signal. **Forwarding the seam's turn-boundary `flush()` hint to the OTel provider's `forceFlush()`.** Shipped in the first revival round, then removed after three review rounds each found a new silent-loss path in the same wrapper state: a dispose racing an in-flight flush (the SDK's concurrent-flush guard makes shutdown's internal drain skip), overlapping hints displacing the retained promise, and the provider's fixed 30-second flush timeout rejecting while the processor still drains. Every path exists only because the forwarding made this backend the process's second flusher against undocumented SDK internals from the upstream experimental tree; with no `flush()` implemented, the batch processor is the only flusher, its `scheduledDelayMillis` (already deployment-tunable through the `processor` passthrough) governs export cadence, and `shutdown()`'s drain is complete by construction. Reinstate only if a deployment states a turn-boundary latency requirement `scheduledDelayMillis` cannot meet — and then by calling the retained `BatchLogRecordProcessor`'s own `forceFlush()`, never the provider's timeout-wrapped one. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index 9b2cf65754..371e8f9fbf 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -28,7 +28,7 @@ Status: implemented **映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 -**handoff 游标未存活时全量回放日志(重新导出构造函数种子)。** 首轮复活曾交付此方案,其后收窄:收养现在从会话的构造边界起回放(`Session.firstLiveSeq`,即构造函数种子长度,这一事实会话早已校验过却未曾暴露;`header.seedLength` 不能胜任:它是持久保存的 fork 谱系(lineage)值,而恢复会话的构造函数种子是其完整的已存储日志)。恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀也已在父会话的流中发出;再次导出任何一者,都会让每次恢复为其完整历史重复付费,并在没有原生摄取去重的 OTLP 后端上使查询时的计数翻倍。接收端基于 `session.parent_id` + `session.seed_length` 拼接 fork 谱系。此次收窄放弃的内容与至多一次立场一致:恢复不再回填上一个进程未能投递的记录(彼时遥测未挂载,或崩溃时仍在队列中)——这本是全量回放唯一的真实收益,代价却由常见情形承担。提出回填要求的部署需要的是上文已推迟的 outbox,而不是回放。 +**handoff 游标未存活时全量回放日志(重新导出构造函数种子)。** 首轮复活曾交付此方案,其后收窄:收养现在从会话的构造边界起回放(`Session.firstLiveSeq`,即构造函数种子长度,这一事实会话早已校验过却未曾暴露;`header.seedLength` 不能胜任:它是持久保存的 fork 谱系(lineage)值,而恢复会话的构造函数种子是其完整的已存储日志)。恢复会话的历史已由上一个进程以同一 id 发出,fork 继承的前缀也已在父会话的流中发出;再次导出任何一者,都会让每次恢复为其完整历史重复付费,并在没有原生摄取去重的 OTLP 后端上使查询时的计数翻倍。接收端基于 `session.parent_id` + `session.seed_length` 拼接 fork 谱系。此次收窄放弃的内容与至多一次立场一致:恢复不再回填上一个进程未能投递的记录(彼时遥测未挂载,或崩溃时仍在队列中)——这本是全量回放唯一的真实收益,代价却由常见情形承担。提出回填要求的部署需要的是上文已推迟的 outbox,而不是回放。该边界同样吞掉 `SessionPersistence.load()` 修复被崩溃打断的日志时写入的合成轮次关闭事件(它们落在 `firstLiveSeq` 之前,尽管在上一个进程中从未存在过)。这是有意为之,而非附带效果:远端轮次的真实尾部记录已随崩溃进程的队列一同消亡,导出合成关闭事件无法补全该轮次,只会让一个未完成的轮次看起来已经关闭。导出的流忠实于崩溃进程实际发出的内容;接收端会把恢复后的流中一个从未关闭的轮次读作「上一个进程死在了该轮次之内」(OTel README 陈述了这条规则),其后干净的 `shutdown` 标记也只证明恢复后进程自身的退出。若为让修复以实时事件的身份导出而将修复前边界贯穿 load/prepare 传递,将使三个包相互耦合,只为抹除这一信号。 **将 seam 的轮次边界 `flush()` 提示转发到 OTel provider 的 `forceFlush()`。** 首轮复活曾交付此转发,其后移除:三轮评审在同一份包装层状态中各发现一条新的静默丢失路径——dispose 与进行中的 flush 之间的竞态(SDK 的并发 flush 防护会令 shutdown 的内部排空被跳过)、相互重叠的提示顶掉留存的 promise、以及 provider 固定的 30 秒 flush 超时在批处理器仍在排空时便 reject。这些路径存在的唯一原因,是该转发让这个后端成为进程内第二个执行 flush 的组件,面对的还是上游实验性(experimental)源码树中未见诸文档的 SDK 内部行为;不实现 `flush()` 时,批处理器就是唯一执行 flush 的组件,其 `scheduledDelayMillis`(已可由部署方经 `processor` passthrough 调优)决定导出节奏,`shutdown()` 的排空从构造上就是完整的。仅当某个部署提出 `scheduledDelayMillis` 无法满足的轮次边界延迟要求时才恢复此转发——且届时应调用留存的 `BatchLogRecordProcessor` 自身的 `forceFlush()`,绝不调用 provider 那个带超时包装的版本。 diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index c53fa68694..32d10fd39d 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/README.i18n.yaml @@ -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 -README.md: 8dd2c4f6535d6d3ea772f7669185f62c4694ca43 -README.zh.md: 42bc9b7ddbbf6d9cb104de35d9489e6e583e1df7 +README.md: 6ead0ec7b7053551676b482dd4c905c8cd7cc1fe +README.zh.md: 852242bfc4ac719660f31935167d2f4fa0f58f2e diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 8dd2c4f653..6ead0ec7b7 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -25,7 +25,7 @@ Records carry the complete `event.data` as the seam's `telemetry/redact` waterfa ## Field mapping -Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record absence (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). The marker means telemetry stopped observing the session cleanly — emitted at the session's own disposal, or at application teardown for sessions still running then; a marker followed by more of that session's events is a telemetry reload, not a session restart. Streams are not self-contained across lineage: a resumed session continues its own id's stream from where the previous process left off, and a forked session's stream starts at its inherited boundary — its prefix lives in the parent's stream, stitched via `session.parent_id` + `session.seed_length`. +Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record absence (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). The marker means telemetry stopped observing the session cleanly — emitted at the session's own disposal, or at application teardown for sessions still running then; a marker followed by more of that session's events is a telemetry reload, not a session restart. Streams are not self-contained across lineage: a resumed session continues its own id's stream from where the previous process left off, and a forked session's stream starts at its inherited boundary — its prefix lives in the parent's stream, stitched via `session.parent_id` + `session.seed_length`. One consequence of continuing rather than replaying: a turn left open mid-stream and never closed marks the previous process dying inside it. The local log is repaired with synthetic closers at resume, but those repairs are never exported — the wire stream stays faithful to what the crashed process actually shipped, and a later clean `shutdown` marker attests only to the resumed process's own exit. ## Model Experience diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 42bc9b7ddb..852242bfc4 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -25,7 +25,7 @@ ## 字段映射 -seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`;`severity` → `severityNumber`/`severityText`(INFO 9 / WARN 13 / ERROR 17);`body` → 结构化日志 body;`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重、按严重级别告警,并通过 `shutdown` 记录的缺失检测崩溃(一个曾有活动、没有 `shutdown` 运维记录、且已然陈旧的会话,就是未干净结束的会话)。该标记的含义是遥测干净地停止了对该会话的观察:它在会话自身 dispose(资源释放)时发出,对于届时仍在运行的会话,则在应用拆卸时发出;标记之后又出现该会话的更多事件,说明发生的是遥测重载,而不是会话重启。跨谱系(lineage)的流并不自足:恢复的会话在其自身 id 的流上从上一个进程停止之处继续;fork 出的会话,其流从继承边界开始,前缀位于父会话的流中,由接收端基于 `session.parent_id` + `session.seed_length` 拼接。 +seam 记录 → SDK 日志记录:`time` → `timestamp`/`observedTimestamp`;`severity` → `severityNumber`/`severityText`(INFO 9 / WARN 13 / ERROR 17);`body` → 结构化日志 body;`attributes` 原样照搬。接收端基于 `(session.id, event.seq)` 去重、按严重级别告警,并通过 `shutdown` 记录的缺失检测崩溃(一个曾有活动、没有 `shutdown` 运维记录、且已然陈旧的会话,就是未干净结束的会话)。该标记的含义是遥测干净地停止了对该会话的观察:它在会话自身 dispose(资源释放)时发出,对于届时仍在运行的会话,则在应用拆卸时发出;标记之后又出现该会话的更多事件,说明发生的是遥测重载,而不是会话重启。跨谱系(lineage)的流并不自足:恢复的会话在其自身 id 的流上从上一个进程停止之处继续;fork 出的会话,其流从继承边界开始,前缀位于父会话的流中,由接收端基于 `session.parent_id` + `session.seed_length` 拼接。继续而非回放的一个后果:流中一个开启后再未关闭的轮次,标志着上一个进程死在了该轮次之内。恢复时本地日志会以合成的关闭事件修复,但这些修复绝不导出:导出的流忠实于崩溃进程实际发出的内容,其后干净的 `shutdown` 标记也只证明恢复后进程自身的退出。 ## 模型体验 From f11dc28f1f923a66fc06eba0147865cd811d101a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 19:54:55 +0800 Subject: [PATCH 65/70] test(web): record the user-bubble action row in the answered golden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The answered golden omitted the `复制` / `在新对话中分支` / `编辑` buttons that `MessageItem`'s `UserActions` renders under every user bubble, so the scenario failed against the built client. They are unconditional in the accessibility tree — the `@media (hover: hover)` rule only sets `opacity`, which does not remove a node from an aria snapshot — so there is no message-row state to stabilize before capture; the golden was simply recorded against a stale `lib/`. A partially failed `pnpm run build` had left the client bundles from before its failure current and the rest not. The Agent Note records that trap: confirm the build exited zero before refreshing a golden, and note that untracked directories under `packages/` are compiled too, so a leftover from another branch fails the build for reasons the diff does not explain. The same omission is present in the other 15 committed web goldens on master, where 9 tests across 7 files fail for this reason on a pristine tree. That is pre-existing and belongs to whoever landed `UserActions`; this commit fixes only the golden this PR already touches. --- ...026-07-27-question-composer-rows-do-not-shrink.i18n.yaml | 4 ++-- .../2026-07-27-question-composer-rows-do-not-shrink.md | 2 ++ .../2026-07-27-question-composer-rows-do-not-shrink.zh.md | 2 ++ .../tests/snapshots/question-composer/answered.expected.md | 6 ++++++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml index 0a1e35ea19..8159e53e1f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml @@ -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/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md -2026-07-27-question-composer-rows-do-not-shrink.md: 519baa831b05cd41907edb6441c65c104a571a9e -2026-07-27-question-composer-rows-do-not-shrink.zh.md: f4d24bd7086735cac795223f22d49db81bf41fde +2026-07-27-question-composer-rows-do-not-shrink.md: 52f2857185f7dc150236083eb1574bcab574dc60 +2026-07-27-question-composer-rows-do-not-shrink.zh.md: a8d3a08051757d04b6df3ca2b094cd9268227040 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md index 519baa831b..52f2857185 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md @@ -44,3 +44,5 @@ The web e2e composer scenario asserts the invariant on the live composer at thre Confirmed both directions against the built client: with `flex-shrink: 0` reverted the scenario fails (`scrolls: false`, 6.5px spill), and with it restored it passes. A standalone geometry sweep over 340 viewport sizes (420-1600 x 320-960) went from 86 sizes with copy outside a row box to zero. The assertion is replay-only: record mode must reach the fixture write rather than aborting on layout. Note that the composer ships as a client-module bundle, so `pnpm run build:web` alone does not pick up a change to `QuestionComposer.module.css` — the package build must run for the browser lane to see it. + +A stale `lib/` makes the browser lane assert against an older client than the tree, and a `pnpm run build` that fails part-way leaves exactly that: the packages built before the failure are current, the rest are not. Refreshing a golden in that state records the older client's surface. Confirm the build exited zero before capturing, and note that untracked directories under `packages/` are compiled too — a leftover from another branch can fail the build for reasons the diff does not explain. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md index f4d24bd708..a8d3a08051 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md @@ -44,3 +44,5 @@ Web e2e 的 composer 场景会在三个被压缩的容器高度(900x520/440 在构建产物客户端上双向确认过:撤销 `flex-shrink: 0` 后该场景失败(`scrolls: false`,6.5px 溢出),恢复后通过。一次覆盖 340 种视口尺寸(420-1600 x 320-960)的独立几何遍历,从 86 种尺寸存在文案落在行盒之外,降到 0 种。 该断言仅在回放模式下执行:录制模式必须走到写入 fixture 那一步,而不是在布局检查处中断。另需注意,composer 以客户端模组包的形式发布,因此单跑 `pnpm run build:web` 不会带上对 `QuestionComposer.module.css` 的改动——必须执行包构建,浏览器测试通道才能看到它。 + +`lib/` 陈旧会让浏览器测试通道对着一个比工作树更旧的客户端做断言,而中途失败的 `pnpm run build` 留下的正是这种状态:失败之前构建的那些包是新的,其余不是。在这种状态下刷新 golden,记录下来的是旧客户端的界面。抓取之前先确认构建以 0 退出;另需注意 `packages/` 下的未跟踪目录同样会被编译——来自另一个分支的遗留物可能以 diff 无法解释的原因让构建失败。 diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 0ac91eed92..3594459176 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -7,6 +7,12 @@ - tab "Trajectory" - tab "Waterfall" - text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop." +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img - text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that. From bf76c52d76a5dff4b5c77cf2c8cfeb7d5f305fd9 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 27 Jul 2026 20:11:30 +0800 Subject: [PATCH 66/70] fix(telemetry): reject non-positive maxExportBatchSize at plugin load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding, pinned red-first: the SDK accepts processor.maxExportBatchSize <= 0 (or fractional), but its shutdown drain then splices empty batches without consuming the queue — disposing telemetry hangs forever whenever records are queued. The constructor now rejects a non-positive-integer batch size before building the SDK processor, per the misconfiguration-fails-loud rule; everything else in the processor block remains the SDK's verbatim passthrough. --- .../telemetry/session-telemetry-otel/README.i18n.yaml | 4 ++-- packages/telemetry/session-telemetry-otel/README.md | 2 +- packages/telemetry/session-telemetry-otel/README.zh.md | 2 +- packages/telemetry/session-telemetry-otel/src/index.ts | 8 ++++++++ .../telemetry/session-telemetry-otel/tests/otel.spec.ts | 4 ++++ 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index 32d10fd39d..df98e01d2e 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/README.i18n.yaml @@ -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 -README.md: 6ead0ec7b7053551676b482dd4c905c8cd7cc1fe -README.zh.md: 852242bfc4ac719660f31935167d2f4fa0f58f2e +README.md: e345449033b066103fb14e546e8d9909d68fe696 +README.zh.md: 6cad58ebc1ad9bab6dc722fcf0d702f500891d32 diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 6ead0ec7b7..e345449033 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -17,7 +17,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th processor: {} # optional; passed verbatim to BatchLogRecordProcessor ``` -`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load. Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. +`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load (as does a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown). Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. ## What leaves the machine diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 852242bfc4..6cad58ebc1 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -17,7 +17,7 @@ processor: {} # optional; passed verbatim to BatchLogRecordProcessor ``` -`exporter.url` 是本包唯一自行校验的字段:必填、无默认值、必须能解析为 `http(s)`,因此缺失端点会在插件加载时失败。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。 +`exporter.url` 是本包唯一自行校验的字段:必填、无默认值、必须能解析为 `http(s)`,因此缺失端点会在插件加载时失败(`processor.maxExportBatchSize` 不是正整数时同样如此:SDK 会接受该值,随后却在关闭时因它挂起)。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。 ## 哪些数据会离开本机 diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index 5a0d62cb10..85dd75f275 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -106,6 +106,14 @@ export class TelemetryOtel extends Telemetry { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error(`session-telemetry-otel: exporter.url must be http(s), got ${parsed.protocol}`) } + // The one processor field checked beyond the SDK's own validation: the + // SDK accepts a non-positive batch size, but its shutdown drain then + // splices empty batches without consuming the queue — dispose would hang + // forever with records queued. Misconfiguration fails at load instead. + const batchSize = config.processor?.maxExportBatchSize + if (batchSize !== undefined && (!Number.isInteger(batchSize) || batchSize < 1)) { + throw new Error(`session-telemetry-otel: processor.maxExportBatchSize must be a positive integer, got ${String(batchSize)}`) + } this.provider = new LoggerProvider({ resource: resourceFromAttributes({ 'service.name': APP_IDENTITY.product, diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index 9069ec89da..0cba1ccdf6 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -202,6 +202,10 @@ describe('TelemetryOtel config fails loud', () => { [{ exporter: { url: '' } }, /exporter\.url is required/], [{ exporter: { url: 'not a url' } }, /not a valid URL/], [{ exporter: { url: 'ftp://collector' } }, /must be http\(s\)/], + // The SDK accepts a non-positive batch size but its shutdown drain then + // splices empty batches forever — dispose would hang, so reject at load. + [{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/], + [{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/], ])('rejects %j at plugin load', async (config, message) => { const ctx = new Context() await ctx.plugin(SessionStore) From 3e659db0b79a81bb80c28327ddab53f4552833ea Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 20:20:12 +0800 Subject: [PATCH 67/70] docs(notes): localize the Chinese Agent Note headings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chinese counterpart kept its title and every section heading in English. The bilingual contract localizes heading text; only the `# Agent Note:` prefix and the `Status:` token stay verbatim, matching the renderings already used across the note corpus (问题 / 决策 / 曾考虑的替代方案 / 后果 / 验证). Pairing hashes re-recorded. --- ...27-question-composer-rows-do-not-shrink.i18n.yaml | 2 +- ...-07-27-question-composer-rows-do-not-shrink.zh.md | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml index 8159e53e1f..3e0a40ee72 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md 2026-07-27-question-composer-rows-do-not-shrink.md: 52f2857185f7dc150236083eb1574bcab574dc60 -2026-07-27-question-composer-rows-do-not-shrink.zh.md: a8d3a08051757d04b6df3ca2b094cd9268227040 +2026-07-27-question-composer-rows-do-not-shrink.zh.md: 8d268c453ba4a7c2d6f3f197ef2fd937a23b5cb8 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md index a8d3a08051..8d268c453b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md @@ -1,10 +1,10 @@ -# Agent Note: Question-composer option rows are scroll content, not the slack absorber +# Agent Note: 提问 composer 的选项行是滚动内容,而非空间不足时的吸收方 Status: implemented [English](2026-07-27-question-composer-rows-do-not-shrink.md) | 中文 -## Problem +## 问题 提问 composer 的卡片会按视口设上限(`max-height: min(60vh, 520px)`),并让选项列表自行滚动,这样在成批提问时标题和底部操作按钮始终可达。但当 composer 的容器变矮时(窗口较小,或视口偏矮且详情面板处于展开状态),选项行会互相叠在一起,也会叠到问题标题上。 @@ -12,7 +12,7 @@ Status: implemented 只有文案会折行的选项行才能复现该问题。文案单行即可容纳的行,其内容与 42px 最小高度之间尚有余量,被压缩也看不出来——这正是既有 e2e fixture(测试前置数据)(选项为 `Blue`/`Green`,无描述)在任何尺寸下都渲染正常的原因。 -## Decision +## 决策 `.option` 与 `.custom` 声明 `flex-shrink: 0`。 @@ -20,7 +20,7 @@ Status: implemented `.header` 与 `.footer` 出于同样的原因,已在卡片层级带有 `flex-shrink: 0`;选项列表的子元素正是这条规则缺失的另一半。 -## Alternatives considered +## 曾考虑的替代方案 **在被压缩的行内裁剪文案或加省略号(对 `.option` 设 `overflow: hidden`)。** 这样一条声明就能消除重叠,且不必重新考虑布局。之所以否决:它把一个可见缺陷换成了一个无声缺陷——行仍保持 42px,而选项描述的第二行会在卡片最紧张的那些尺寸上直接消失。描述属于影响决策的内容,不是装饰。 @@ -30,14 +30,14 @@ Status: implemented **把折行文案限制为单行(对 `.description` 设 `white-space: nowrap` 加省略号)。** 行永远不会折行,因此被压缩时也永远不会溢出。否决理由与裁剪相同,此外它还为了修一个窄视口缺陷,而牺牲了空间充裕的宽视口渲染效果。 -## Consequences +## 后果 - 被压缩的 composer 会滚动其选项列表,而不是让它互相重叠:在 900x380 处,该列表报告 `scrollHeight` 为 200、`clientHeight` 为 114,并给出滚动条;此前两者相等,不给滚动条。 - 选项行在任何视口尺寸下都保留完整的折行文案。不裁剪、不加省略号,宽视口下的渲染保持不变(该规则仅在 flex 盒子空间不足时才生效)。 - 由于高度不足不再被行部分吸收,卡片现在更早进入滚动状态。这正是该高度上限想要的行为,也意味着在此前只会无声画错列表的情形下,矮容器现在会显示滚动条。 - 该场景录制的问题,比它主要测试的那次往返所需的长度更长。这个代价是有意付出的:没有折行文案,该布局不变式无法被证伪,而为一条 CSS 规则再加一份 fixture 会更糟。 -## Verification +## 验证 Web e2e 的 composer 场景会在三个被压缩的容器高度(900x520/440/380)上,对活动的 composer 断言该运行时不变式:每个选项行的子元素都留在该行的边框盒之内。两道守卫防止该断言空洞地成立——必须至少有一行处于折行状态(这是唯一会溢出的形态),且 `.options` 必须确实处在滚动状态(证明容器确实触及了高度上限)。该场景录制的问题现在带有较长的选项描述,正是为此;没有折行文案,这条断言不可能失败。 From 4182dbdc86262876a452b3a3db8ac54dc3ee4f52 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 27 Jul 2026 20:38:22 +0800 Subject: [PATCH 68/70] test(snapshot): refresh cordis-inspect-jsdoc for Session.firstLiveSeq The self-referential inspect tool surfaces Session's public API in its transcript; the construction-boundary field added in bc8512d17 now appears there. Keyless refresh; only the affected scenario re-recorded. --- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index e51933cdf3..4539ba077a 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} From b5395f9415378e32ebf4f8ce19201b2d2a7b27c9 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 27 Jul 2026 21:11:15 +0800 Subject: [PATCH 69/70] docs(agent-note): record that reproducing the shortfall needs a short viewport The card cap is viewport-relative (min(60vh, 520px)), so shrinking the conversation column clips the card without under-allocating it. Anything demonstrating or measuring this defect outside the e2e scenario has to change the viewport. --- .../2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml | 4 ++-- .../2026-07-27-question-composer-rows-do-not-shrink.md | 2 ++ .../2026-07-27-question-composer-rows-do-not-shrink.zh.md | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml index 3e0a40ee72..2979127ed1 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.i18n.yaml @@ -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/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md -2026-07-27-question-composer-rows-do-not-shrink.md: 52f2857185f7dc150236083eb1574bcab574dc60 -2026-07-27-question-composer-rows-do-not-shrink.zh.md: 8d268c453ba4a7c2d6f3f197ef2fd937a23b5cb8 +2026-07-27-question-composer-rows-do-not-shrink.md: 2e0e9b9ca6b141a200ba53d8b6f6f0cad5f7e89d +2026-07-27-question-composer-rows-do-not-shrink.zh.md: 845d9ee883ad1c2c7abc63a01fce626be924b87f diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md index 52f2857185..2e0e9b9ca6 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.md @@ -45,4 +45,6 @@ Confirmed both directions against the built client: with `flex-shrink: 0` revert The assertion is replay-only: record mode must reach the fixture write rather than aborting on layout. Note that the composer ships as a client-module bundle, so `pnpm run build:web` alone does not pick up a change to `QuestionComposer.module.css` — the package build must run for the browser lane to see it. +Reproducing the shortfall requires a short viewport, not a short container. The cap is `min(60vh, 520px)`, so shrinking the conversation column below the card's own height clips the card without under-allocating it — the rows keep their full height and nothing spills. Anything demonstrating or measuring this defect outside the e2e scenario has to change the viewport. + A stale `lib/` makes the browser lane assert against an older client than the tree, and a `pnpm run build` that fails part-way leaves exactly that: the packages built before the failure are current, the rest are not. Refreshing a golden in that state records the older client's surface. Confirm the build exited zero before capturing, and note that untracked directories under `packages/` are compiled too — a leftover from another branch can fail the build for reasons the diff does not explain. diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md index 8d268c453b..845d9ee883 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-27-question-composer-rows-do-not-shrink.zh.md @@ -45,4 +45,6 @@ Web e2e 的 composer 场景会在三个被压缩的容器高度(900x520/440 该断言仅在回放模式下执行:录制模式必须走到写入 fixture 那一步,而不是在布局检查处中断。另需注意,composer 以客户端模组包的形式发布,因此单跑 `pnpm run build:web` 不会带上对 `QuestionComposer.module.css` 的改动——必须执行包构建,浏览器测试通道才能看到它。 +要复现这种空间不足,需要的是矮视口,而不是矮容器。高度上限为 `min(60vh, 520px)`,因此把会话列压到比卡片自身高度更矮,只会裁剪卡片,而不会让它空间不足——各行仍保持完整高度,也不会有任何溢出。凡是在 e2e 场景之外演示或测量该缺陷的手段,都必须改变视口。 + `lib/` 陈旧会让浏览器测试通道对着一个比工作树更旧的客户端做断言,而中途失败的 `pnpm run build` 留下的正是这种状态:失败之前构建的那些包是新的,其余不是。在这种状态下刷新 golden,记录下来的是旧客户端的界面。抓取之前先确认构建以 0 退出;另需注意 `packages/` 下的未跟踪目录同样会被编译——来自另一个分支的遗留物可能以 diff 无法解释的原因让构建失败。 From 0af16387ae1c244d2160c2408a3e6c61a39a1a2b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:30:28 +0800 Subject: [PATCH 70/70] fix(telemetry): rename record waterfall --- ...23-session-telemetry-otel-revival.i18n.yaml | 6 +++--- ...026-07-23-session-telemetry-otel-revival.md | 4 ++-- ...-07-23-session-telemetry-otel-revival.zh.md | 4 ++-- docs/cordis-catalog/events.md | 13 +++++++------ docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- .../tests/fixtures/telemetry-otel.cordis.yml | 2 +- .../tests/fixtures/telemetry-redact-rule.ts | 4 ++-- packages/cordis/tool-cordis/src/api-catalog.ts | 8 ++++---- packages/telemetry/README.i18n.yaml | 6 +++--- packages/telemetry/README.md | 2 +- packages/telemetry/README.zh.md | 2 +- .../session-telemetry-otel/README.i18n.yaml | 6 +++--- .../telemetry/session-telemetry-otel/README.md | 2 +- .../session-telemetry-otel/README.zh.md | 2 +- .../session-telemetry/README.i18n.yaml | 6 +++--- packages/telemetry/session-telemetry/README.md | 4 ++-- .../telemetry/session-telemetry/README.zh.md | 4 ++-- .../session-telemetry/src/coordinator.ts | 8 ++++---- .../telemetry/session-telemetry/src/index.ts | 7 ++++--- .../session-telemetry/tests/redact.spec.ts | 18 +++++++++--------- 21 files changed, 57 insertions(+), 55 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index 8c4c025c86..cd9e4f7e9f 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -1,6 +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 -2026-07-23-session-telemetry-otel-revival.md: 1c855ad8d3263f5ff3343b480983f7cf14d9d4c6 -2026-07-23-session-telemetry-otel-revival.zh.md: 371e8f9fbfbdcc7c74fa5ae39406ea4bf159bc81 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +2026-07-23-session-telemetry-otel-revival.md: a58598d8a956d47cb0cf6aa3e659f38314bc4b17 +2026-07-23-session-telemetry-otel-revival.zh.md: cc09717e349d5ae2ab5157bf46de30b1823c775f diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index 1c855ad8d3..a58598d8a9 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -13,7 +13,7 @@ Every deployment that wants harness sessions in an observability stack must hand `packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go and owns what leaves in them: - **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: adoption with cursor read-back, the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), the fixed first-chunk-per-(turn, step) projection, the `agent/error` relay, and dispose-time `shutdown` records. -- **The `telemetry/redact` waterfall** — the delta over the branch version. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten. +- **The `telemetry/record` waterfall** — the delta over the branch version and the seam's redaction extension point. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten. - **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `exporter.url` is required and validated at load; unmounted or unconfigured, nothing leaves the process. The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs — delivery is best-effort (at-most-once across a crash), which the READMEs state plainly. @@ -34,4 +34,4 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry ## Consequences -A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/redact` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. +A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/record` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index 371e8f9fbf..cc09717e34 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -13,7 +13,7 @@ Status: implemented `packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向并对导出内容负责: - **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的收养、逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、固定的每 (turn, step) 首 chunk 投影、`agent/error` 转发、以及 dispose 时的 `shutdown` 记录。 -- **`telemetry/redact` waterfall** —— 相对分支版本的增量。每条记录抵达任何 backend 前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 +- **`telemetry/record` waterfall** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何 backend 前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 - **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程。 边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。 @@ -34,4 +34,4 @@ Status: implemented ## Consequences -部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/redact` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 29cb51b717..aa53b02ff1 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -894,14 +894,15 @@ Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/syst ## `telemetry/*` -### `telemetry/redact` — waterfall +### `telemetry/record` — waterfall -Redact one outbound record before it reaches the backend — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten. +Transform one outbound record before it reaches the backend. This waterfall is the seam's redaction extension point. It ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten. ```ts cordis-catalog /** - * Redact one outbound record before it reaches the backend — the seam's - * scrubbing extension point. The seam ships NO rules of its own: the + * Transform one outbound record before it reaches the backend. This + * waterfall is the seam's redaction extension point. It ships NO rules + * of its own: the * innermost `next()` passes the record through unchanged, and with no * listener mounted records reach the backend as captured, so exported * data is exactly as clean as the rules a deployment mounts. Listeners @@ -915,10 +916,10 @@ Redact one outbound record before it reaches the backend — the seam's scrubbin * copy; listeners return a (possibly new) record and must not mutate it. * @mode waterfall */ -'telemetry/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord +'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:40`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:41`](../../packages/telemetry/session-telemetry/src/index.ts) ## `tools/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index fff219ece6..8b3326142b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1772,7 +1772,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:134`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:135`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a3c5387097..2fa8767acf 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -48,7 +48,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `telemetry/redact` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:40`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | +| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml index ff81d0116c..34e23b828e 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -1,7 +1,7 @@ # Test-only composition: session-telemetry-otel through the real Loader/app # path, exporting to the mock OTLP collector the driver starts (url via env). # The redact-rule entry models a deployment mounting its own scrub rule on the -# telemetry/redact waterfall — the seam itself ships no rules. +# telemetry/record waterfall — the seam itself ships no rules. - id: cli-mock-llm name: './cli-mock-llm.ts' diff --git a/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts index 10b7081dd3..7a2aa7958a 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts +++ b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts @@ -3,7 +3,7 @@ import type { Context } from 'cordis' /** * Deployment-style redaction rule for the telemetry e2e: scrubs the fixture * credential from body strings, exactly as a real deployment would mount its - * own rules on the `telemetry/redact` waterfall. + * own rules on the `telemetry/record` waterfall. */ const SECRET = /sk-e2efixture[0-9]+/g @@ -22,7 +22,7 @@ export const name = 'telemetry-redact-rule' /** Mount the fixture scrub rule onto the redact waterfall. */ export function apply(ctx: Context): void { - ctx.on('telemetry/redact', (_record, next) => { + ctx.on('telemetry/record', (_record, next) => { const record = next() return { ...record, body: scrub(record.body) } }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 193743fbaa..4cecb115ea 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1280,11 +1280,11 @@ export const EVENT_API: readonly EventApiEntry[] = [ summary: 'Emitted when any prompt provider changes.', }, { - name: 'telemetry/redact', + name: 'telemetry/record', mode: 'waterfall', - signature: '\'telemetry/redact\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord', - jsDoc: '/**\n * Redact one outbound record before it reaches the backend — the seam\'s\n * scrubbing extension point. The seam ships NO rules of its own: the\n * innermost `next()` passes the record through unchanged, and with no\n * listener mounted records reach the backend as captured, so exported\n * data is exactly as clean as the rules a deployment mounts. Listeners\n * stack by transforming `next()`\'s return value; returning without\n * `next()` replaces everything beneath. Dispatched synchronously on the\n * capture hot path inside the coordinator\'s containment: a throwing\n * listener withholds that one record (fail-closed) and never reaches the\n * agent loop. Redaction applies to the exported copy only; the canonical\n * session log is never rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */', - summary: 'Redact one outbound record before it reaches the backend — the seam\'s scrubbing extension point.', + signature: '\'telemetry/record\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord', + jsDoc: '/**\n * Transform one outbound record before it reaches the backend. This\n * waterfall is the seam\'s redaction extension point. It ships NO rules\n * of its own: the\n * innermost `next()` passes the record through unchanged, and with no\n * listener mounted records reach the backend as captured, so exported\n * data is exactly as clean as the rules a deployment mounts. Listeners\n * stack by transforming `next()`\'s return value; returning without\n * `next()` replaces everything beneath. Dispatched synchronously on the\n * capture hot path inside the coordinator\'s containment: a throwing\n * listener withholds that one record (fail-closed) and never reaches the\n * agent loop. Redaction applies to the exported copy only; the canonical\n * session log is never rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */', + summary: 'Transform one outbound record before it reaches the backend.', }, { name: 'tools/change', diff --git a/packages/telemetry/README.i18n.yaml b/packages/telemetry/README.i18n.yaml index 87f24d005e..b10509cb56 100644 --- a/packages/telemetry/README.i18n.yaml +++ b/packages/telemetry/README.i18n.yaml @@ -1,6 +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 -README.md: 3e20e3ce31de36311529eb5d91382b9bb0049ddd -README.zh.md: a7036f4f119b144aa61de1c25836c980256d4db7 +# pnpm run verify-translation-pairing --write packages/telemetry/README.md +README.md: 944cb3f9bac6169feddf8b49bc481cfbe7c6fa9d +README.zh.md: 7be8af93654565cd9fb4d8b4376e0960fd7eb72b diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md index 3e20e3ce31..944cb3f9ba 100644 --- a/packages/telemetry/README.md +++ b/packages/telemetry/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The design — the boundary axiom (the harness's aspect ends at `emit()`; delivery is the reporting SDK's), the `telemetry/redact` waterfall (deployment-mounted rules; the seam ships none), the fixed chunk projection, the handoff cursor, and the operational-record channel — is pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). +Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The design — the boundary axiom (the harness's aspect ends at `emit()`; delivery is the reporting SDK's), the `telemetry/record` waterfall (deployment-mounted redaction rules; the seam ships none), the fixed chunk projection, the handoff cursor, and the operational-record channel — is pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). | Package | Role | |---|---| diff --git a/packages/telemetry/README.zh.md b/packages/telemetry/README.zh.md index a7036f4f11..7be8af9365 100644 --- a/packages/telemetry/README.zh.md +++ b/packages/telemetry/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -面向外部的会话上报:遥测(telemetry)seam 及其 OpenTelemetry 后端。整套设计归档于[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md):边界公理(harness 的职责止于 `emit()`,投递由上报 SDK 负责)、`telemetry/redact` waterfall(瀑布式事件;脱敏规则由部署方挂载,seam 自身不带任何规则)、固定分片投影、handoff 游标,以及运维记录通道。 +面向外部的会话上报:遥测(telemetry)seam 及其 OpenTelemetry 后端。整套设计归档于[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md):边界公理(harness 的职责止于 `emit()`,投递由上报 SDK 负责)、`telemetry/record` waterfall(瀑布式事件;脱敏规则由部署方挂载,seam 自身不带任何规则)、固定分片投影、handoff 游标,以及运维记录通道。 | 包 | 职责 | |---|---| diff --git a/packages/telemetry/session-telemetry-otel/README.i18n.yaml b/packages/telemetry/session-telemetry-otel/README.i18n.yaml index df98e01d2e..e8cafdc470 100644 --- a/packages/telemetry/session-telemetry-otel/README.i18n.yaml +++ b/packages/telemetry/session-telemetry-otel/README.i18n.yaml @@ -1,6 +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 -README.md: e345449033b066103fb14e546e8d9909d68fe696 -README.zh.md: 6cad58ebc1ad9bab6dc722fcf0d702f500891d32 +# pnpm run verify-translation-pairing --write packages/telemetry/session-telemetry-otel/README.md +README.md: 9b208e291e77bee50d9d4fd14808268dca75f2db +README.zh.md: f36cfe74146b779c4ddb1101227cb45a06f0968c diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index e345449033..9b208e291e 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -21,7 +21,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th ## What leaves the machine -Records carry the complete `event.data` as the seam's `telemetry/redact` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/redact` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. +Records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. ## Field mapping diff --git a/packages/telemetry/session-telemetry-otel/README.zh.md b/packages/telemetry/session-telemetry-otel/README.zh.md index 6cad58ebc1..f36cfe7414 100644 --- a/packages/telemetry/session-telemetry-otel/README.zh.md +++ b/packages/telemetry/session-telemetry-otel/README.zh.md @@ -21,7 +21,7 @@ ## 哪些数据会离开本机 -记录携带完整的 `event.data`,内容以 seam 的 `telemetry/redact` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `telemetry/redact` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。 +记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。 ## 字段映射 diff --git a/packages/telemetry/session-telemetry/README.i18n.yaml b/packages/telemetry/session-telemetry/README.i18n.yaml index 6c905a5ea1..71475fc8fe 100644 --- a/packages/telemetry/session-telemetry/README.i18n.yaml +++ b/packages/telemetry/session-telemetry/README.i18n.yaml @@ -1,6 +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 -README.md: 76f9b242ca0b4779a9ab10e5ae93bab1ffb5e581 -README.zh.md: d930dc92e41e85c597aaeff840e6e6b03712b06d +# pnpm run verify-translation-pairing --write packages/telemetry/session-telemetry/README.md +README.md: a3820ee8e91a5c513c08df76b149cf3a670ee2ca +README.zh.md: d607a4ab4d953242ede53ff0b5e50bdf69e29ea4 diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 76f9b242ca..a3820ee8e9 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -14,7 +14,7 @@ The coordinator registers, all through the composing fiber's effects: `session/c ## The redact waterfall -Every record passes the `telemetry/redact` waterfall between projection and `emit()` — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Redaction applies to the exported copy only; the canonical session log is never rewritten. +Every record passes the `telemetry/record` waterfall between projection and `emit()` — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Redaction applies to the exported copy only; the canonical session log is never rewritten. ## The handoff cursor @@ -39,4 +39,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Best-effort delivery** — the cursor marks handed-off, not delivered; a session torn down inside a reload window cannot be re-adopted; whatever sits in a backend queue at crash time is lost. A durable outbox (spool, per-sink cursors, at-least-once) is deferred until a deployment states a crash-loss requirement — see [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). -- **No built-in redaction rules** — with no `telemetry/redact` listener mounted, records leave the process exactly as captured, including any credentials embedded in file contents or command output; a deployment exporting to a shared collector owns its rule set. +- **No built-in redaction rules** — with no `telemetry/record` listener mounted, records leave the process exactly as captured, including any credentials embedded in file contents or command output; a deployment exporting to a shared collector owns its rule set. diff --git a/packages/telemetry/session-telemetry/README.zh.md b/packages/telemetry/session-telemetry/README.zh.md index d930dc92e4..d607a4ab4d 100644 --- a/packages/telemetry/session-telemetry/README.zh.md +++ b/packages/telemetry/session-telemetry/README.zh.md @@ -14,7 +14,7 @@ ## 脱敏 waterfall -每条记录在投影与 `emit()` 之间都要经过 `telemetry/redact` waterfall(瀑布式事件),这是该 seam 的擦除扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式扣下这一条记录。脱敏只作用于导出副本;权威会话日志永不改写。 +每条记录在投影与 `emit()` 之间都要经过 `telemetry/record` waterfall(瀑布式事件),这是该 seam 的擦除扩展点。seam 自身不带任何规则:最内层的 `next()` 原样透传记录,因此未挂载监听器时,记录以捕获时的原样到达后端;导出数据能干净到什么程度,恰恰取决于部署方挂载了什么规则。监听器通过变换 `next()` 的返回值来堆叠;不调用 `next()` 就返回,即替换其下方的全部逻辑;抛出异常的监听器会在协调器的隔离范围内以 fail-closed 方式扣下这一条记录。脱敏只作用于导出副本;权威会话日志永不改写。 ## handoff 游标 @@ -39,4 +39,4 @@ ## 已知限制与延期工作 - **尽力而为的投递**:游标标记的是已交接而非已投递;在重载窗口内被拆除的会话无法重新收养;崩溃时留在后端队列中的内容会丢失。持久 outbox(spool、每 sink 游标、at-least-once)推迟到有部署方提出明确的崩溃丢失要求时再实现;见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)。 -- **不内置脱敏规则**:未挂载 `telemetry/redact` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。 +- **不内置脱敏规则**:未挂载 `telemetry/record` 监听器时,记录以捕获时的原样离开进程,包括文件内容或命令输出中内嵌的任何凭据;向共享 collector 导出的部署方自行负责其规则集。 diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index a5b552c765..76f3fb1f16 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -2,8 +2,8 @@ * Capture coordinator: the seam's upstream half. Subscribes to the session * firehose plus the one live-bus relay (`agent/error`), applies the fixed * chunk projection, builds logical records, runs each through the - * `telemetry/redact` waterfall (deployment-mounted rules; pass-through when - * none), and hands the result to the backend — synchronously, with every + * `telemetry/record` waterfall (deployment-mounted redaction rules; + * pass-through when none), and hands the result to the backend — synchronously, with every * handler self-contained so a failing backend can never starve other * subscribers (cordis `emit` is stop-on-throw) or touch the agent loop. * Composed by a backend in its constructor. @@ -178,14 +178,14 @@ export class TelemetryCoordinator { } /** - * Run the `telemetry/redact` waterfall over one record and hand the result + * Run the `telemetry/record` waterfall over one record and hand the result * to the backend. The innermost `next` passes the record through unchanged * — the seam ships no rules; exported data is as clean as the listeners a * deployment mounts. Callers run inside {@link contain}, so a throwing * rule withholds the record instead of reaching the loop (fail-closed). */ private handOff(record: TelemetryRecord): void { - this.backend.emit(this.ctx.waterfall('telemetry/redact', record, () => record)) + this.backend.emit(this.ctx.waterfall('telemetry/record', record, () => record)) } /** Forward the turn-end boundary to the backend's optional flush hint. */ diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index b52f80fbbe..ca0f504647 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -22,8 +22,9 @@ declare module 'cordis' { interface Events { /** - * Redact one outbound record before it reaches the backend — the seam's - * scrubbing extension point. The seam ships NO rules of its own: the + * Transform one outbound record before it reaches the backend. This + * waterfall is the seam's redaction extension point. It ships NO rules + * of its own: the * innermost `next()` passes the record through unchanged, and with no * listener mounted records reach the backend as captured, so exported * data is exactly as clean as the rules a deployment mounts. Listeners @@ -37,7 +38,7 @@ declare module 'cordis' { * copy; listeners return a (possibly new) record and must not mutate it. * @mode waterfall */ - 'telemetry/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord + 'telemetry/record'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord } } diff --git a/packages/telemetry/session-telemetry/tests/redact.spec.ts b/packages/telemetry/session-telemetry/tests/redact.spec.ts index c4b9d4d5bb..6e71d61627 100644 --- a/packages/telemetry/session-telemetry/tests/redact.spec.ts +++ b/packages/telemetry/session-telemetry/tests/redact.spec.ts @@ -1,5 +1,5 @@ /** - * The `telemetry/redact` waterfall contract: pass-through when no listener is + * The `telemetry/record` waterfall contract: pass-through when no listener is * mounted, listener stacking and replacement, ops-record coverage, the * untouched canonical log, and the fail-closed containment of a throwing rule. */ @@ -35,7 +35,7 @@ async function setup() { return { ctx, backend, fiber } } -describe('telemetry/redact waterfall', () => { +describe('telemetry/record waterfall', () => { it('passes records through unchanged when no listener is mounted', async () => { const { ctx, backend } = await setup() const session = ctx.sessions.create(SessionId('w')) @@ -46,7 +46,7 @@ describe('telemetry/redact waterfall', () => { it('applies a mounted rule to every outbound record, ops records included', async () => { const { ctx, backend, fiber } = await setup() - ctx.on('telemetry/redact', (_record, next) => { + ctx.on('telemetry/record', (_record, next) => { const record = next() return { ...record, body: { scrubbed: true } } }) @@ -62,7 +62,7 @@ describe('telemetry/redact waterfall', () => { it('keeps the canonical log untouched by a mounted rule', async () => { const { ctx } = await setup() - ctx.on('telemetry/redact', (_record, next) => ({ ...next(), body: null })) + ctx.on('telemetry/record', (_record, next) => ({ ...next(), body: null })) const session = ctx.sessions.create(SessionId('log')) session.append('user/message', { content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const logged = session.events[0]!.data as { content: { text: string }[] } @@ -72,13 +72,13 @@ describe('telemetry/redact waterfall', () => { it('stacks listeners outermost-first around next()', async () => { const { ctx, backend } = await setup() const order: string[] = [] - ctx.on('telemetry/redact', (_record, next) => { + ctx.on('telemetry/record', (_record, next) => { order.push('outer-before') const record = next() order.push('outer-after') return { ...record, attributes: { ...record.attributes, outer: 1 } } }) - ctx.on('telemetry/redact', (_record, next) => { + ctx.on('telemetry/record', (_record, next) => { order.push('inner') const record = next() return { ...record, attributes: { ...record.attributes, inner: 1 } } @@ -92,8 +92,8 @@ describe('telemetry/redact waterfall', () => { it('a listener that skips next() replaces everything beneath it', async () => { const { ctx, backend } = await setup() const inner = { called: false } - ctx.on('telemetry/redact', () => ({ channel: 'ops', time: 0, severity: 'info', attributes: {}, body: 'replaced' } satisfies TelemetryRecord)) - ctx.on('telemetry/redact', (_record, next) => { + ctx.on('telemetry/record', () => ({ channel: 'ops', time: 0, severity: 'info', attributes: {}, body: 'replaced' } satisfies TelemetryRecord)) + ctx.on('telemetry/record', (_record, next) => { inner.called = true return next() }) @@ -105,7 +105,7 @@ describe('telemetry/redact waterfall', () => { it('a throwing rule withholds the record fail-closed without disturbing the log', async () => { const { ctx, backend } = await setup() - ctx.on('telemetry/redact', () => { + ctx.on('telemetry/record', () => { throw new Error('rule exploded') }) const session = ctx.sessions.create(SessionId('closed'))