From e6eb44ed2a2171a4ee5b6a5f1944a2ccec0ea80a Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 17:45:50 +0800 Subject: [PATCH 01/61] feat(telemetry): require explicit opt-in --- ...3-session-telemetry-otel-revival.i18n.yaml | 4 +- ...26-07-23-session-telemetry-otel-revival.md | 4 +- ...07-23-session-telemetry-otel-revival.zh.md | 4 +- ...versioned-gui-welcome-onboarding.i18n.yaml | 4 +- ...-07-30-versioned-gui-welcome-onboarding.md | 2 +- ...-30-versioned-gui-welcome-onboarding.zh.md | 2 +- ...7-31-web-telemetry-default-mount.i18n.yaml | 4 +- .../2026-07-31-web-telemetry-default-mount.md | 21 +-- ...26-07-31-web-telemetry-default-mount.zh.md | 21 +-- ...feedback-gated-session-telemetry.i18n.yaml | 4 +- ...-08-05-feedback-gated-session-telemetry.md | 8 +- ...-05-feedback-gated-session-telemetry.zh.md | 8 +- ...2026-08-10-telemetry-default-off.i18n.yaml | 6 + .../2026-08-10-telemetry-default-off.md | 31 ++++ .../2026-08-10-telemetry-default-off.zh.md | 31 ++++ ...07-17-sdk-follow-up-capabilities.i18n.yaml | 4 +- .../2026-07-17-sdk-follow-up-capabilities.md | 10 +- ...026-07-17-sdk-follow-up-capabilities.zh.md | 8 +- README.i18n.yaml | 4 +- README.md | 2 +- README.zh.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 4 +- apps/cli/reference/README.zh.md | 4 +- apps/cli/src/profile-boot.ts | 2 +- apps/cli/tests/telemetry-switch.spec.ts | 2 +- .../welcome.expected.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 15 +- packages/bundle/base/tests/base.spec.ts | 5 +- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- .../src/onboarding-copy.ts | 6 +- packages/scaffold/scripts/src/telemetry.ts | 13 +- .../scaffold/scripts/tests/scripts.spec.ts | 4 +- packages/scaffold/telemetry/README.i18n.yaml | 4 +- packages/scaffold/telemetry/README.md | 6 +- packages/scaffold/telemetry/README.zh.md | 6 +- packages/scaffold/telemetry/package.json | 3 - .../telemetry/src/consent-resolver.ts | 129 +++------------- packages/scaffold/telemetry/src/index.ts | 11 +- .../telemetry/tests/consent-resolver.spec.ts | 145 ++++-------------- .../scaffold/telemetry/tests/reporter.spec.ts | 4 +- .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session/session-telemetry-otel/README.md | 6 +- .../session-telemetry-otel/README.zh.md | 6 +- .../session-telemetry-otel/src/index.ts | 4 +- .../session-telemetry-otel/tests/otel.spec.ts | 38 +++-- pnpm-lock.yaml | 4 - .../request-response.expected.json | 4 +- 53 files changed, 265 insertions(+), 370 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md create mode 100644 .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md 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 f055b6f81f..4290762ae1 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 .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md -2026-07-23-session-telemetry-otel-revival.md: 67ab0d28a168422a6b787a6088648746cd6079b6 -2026-07-23-session-telemetry-otel-revival.zh.md: d59dcd07e9879e1c7cafc7a104d4f2515596cab8 +2026-07-23-session-telemetry-otel-revival.md: c981a067efcc7107a7b7a9b9344a59f1a3bfe3d9 +2026-07-23-session-telemetry-otel-revival.zh.md: c79d29687feeb1645b04031cc4c300a6465b1c38 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 67ab0d28a1..c981a067ef 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 @@ -14,7 +14,7 @@ Every deployment that wants harness sessions in an observability stack must hand - **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: live adoption with cursor read-back and the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), buffer-free on-demand replay from the canonical log, the fixed first-chunk-per-(turn, step) projection, the live `agent/error` relay, and live dispose-time `shutdown` records. - **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. Its default `FULL` mode requires `exporter.url`; the later [feedback-gated telemetry decision](2026-08-05-feedback-gated-session-telemetry.md) adds `FEEDBACK_ONLY` and `DISABLED` delivery modes without moving the redaction or backend boundary, while [buffer-free feedback replay](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) avoids a second in-memory copy of the session prefix. +- **`@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. `DISABLED` is the default and constructs no transport; the [feedback-gated telemetry decision](2026-08-05-feedback-gated-session-telemetry.md) defines the explicit `FULL` and `FEEDBACK_ONLY` delivery modes, which require `exporter.url`, without moving the redaction or backend boundary. [Buffer-free feedback replay](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) avoids a second in-memory copy of the session prefix. 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. `FULL` preserves that behavior by default, `FEEDBACK_ONLY` replays a canonical-log prefix when feedback is recorded, and `DISABLED` constructs no reporting pipeline; removing the entry remains a silent opt-out, while the disabled mode keeps the local feedback warning. 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. +A deployment adds one `cordis.yml` entry with an OTLP endpoint and explicitly selects `FULL` to stream sessions into an OTel-compatible stack or `FEEDBACK_ONLY` to replay a canonical-log prefix when feedback is recorded. `DISABLED` is the [default](2026-08-10-telemetry-default-off.md) and constructs no reporting pipeline; removing the entry remains a silent opt-out, while the disabled mode keeps the local feedback warning. 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 d59dcd07e9..c79d29687f 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 @@ -14,7 +14,7 @@ Status: implemented - **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的实时纳管与逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、从权威日志进行的无缓冲按需回放、固定的每个(轮次、步骤)组合首分片投影、实时 `agent/error` 转发,以及实时 dispose(资源释放)时的 `shutdown` 记录。 - **`telemetry/record` waterfall(瀑布式事件)** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何后端前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 -- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考后端:OTel JS SDK 日志流水线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。其默认 `FULL` 模式要求 `exporter.url`;后续的[反馈门控遥测决策](2026-08-05-feedback-gated-session-telemetry.md)增加了 `FEEDBACK_ONLY` 与 `DISABLED` 投递模式,但未移动脱敏或后端边界,而[无缓冲反馈回放](../simplification/2026-08-06-buffer-free-feedback-telemetry.md)避免在内存中创建会话前缀的第二份副本。 +- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考后端:OTel JS SDK 日志流水线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`DISABLED` 是默认值,且不构造任何传输;[反馈门控遥测决策](2026-08-05-feedback-gated-session-telemetry.md)定义了需显式启用的 `FULL` 与 `FEEDBACK_ONLY` 投递模式,这两种模式要求 `exporter.url`,且不移动脱敏或后端边界。[无缓冲反馈回放](../simplification/2026-08-06-buffer-free-feedback-telemetry.md)避免在内存中创建会话前缀的第二份副本。 边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),两份 README 对此如实陈述。 @@ -34,4 +34,4 @@ Status: implemented ## 后果 -部署方在 `cordis.yml` 加一个带 OTLP endpoint 的 Cordis 配置项即可把会话流接入任何 OTel 兼容体系。`FULL` 默认保留该行为,`FEEDBACK_ONLY` 在记录反馈时回放权威日志前缀,`DISABLED` 则不构造上报流水线;删除该配置项仍是静默退出方式,而禁用模式会保留本地反馈警告。未挂载规则的部署导出的记录与捕获时完全一致,包括文件内容与命令输出中内嵌的任何凭据。因此,跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。崩溃持久性在上述 outbox 决定重新审议前明确不在范围内。 +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的 Cordis 配置项,并显式选择 `FULL`,即可把会话流接入任何 OTel 兼容体系;选择 `FEEDBACK_ONLY` 则会在记录反馈时回放权威日志前缀。`DISABLED` 是[默认值](2026-08-10-telemetry-default-off.md),且不构造上报流水线;删除该配置项仍是静默退出方式,而禁用模式会保留本地反馈警告。未挂载规则的部署导出的记录与捕获时完全一致,包括文件内容与命令输出中内嵌的任何凭据。因此,跨信任边界的部署必须挂载 `telemetry/record` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是真源。崩溃持久性在上述 outbox 决定重新审议前明确不在范围内。 diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml index 0762a4d131..46d633a37e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.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-30-versioned-gui-welcome-onboarding.md -2026-07-30-versioned-gui-welcome-onboarding.md: 4707769d4fa9fbf184e09a2e73087dfd326070be -2026-07-30-versioned-gui-welcome-onboarding.zh.md: 002a04b1fc6acc4fc7cfee053bdbbb2abaef047e +2026-07-30-versioned-gui-welcome-onboarding.md: c720b822acc613799681671bfdb6820e4397bbe6 +2026-07-30-versioned-gui-welcome-onboarding.zh.md: d52f971140de339ccc7919355b97ac92f9b86bb3 diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md index 4707769d4f..c720b822ac 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md @@ -12,7 +12,7 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check, **The Settings shell coordinates ordered steps.** `settings.onboarding` remains a root-scoped list, but `ui-settings` projects its entry ids and order into one coordinator and mounts only the first incomplete step. The active registrant receives `complete()` and `openSection(id)`; no later step mounts until ownership transfers. The product welcome registers at order `-100`, while `ui-models` retains only the conditional DeepSeek readiness and credential-routing step at order `0`. -**Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. +**Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. The notice states that session telemetry is [disabled by default](2026-08-10-telemetry-default-off.md) and names the `FEEDBACK_ONLY` and `FULL` opt-in modes. **Loopback acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The connection plugin publishes whether the current page uses a loopback authority as `ctx.connection.isLoopback`; hostname classification remains internal to the connection package, and other client plugins consume the service state instead of importing its implementation. A loopback browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md index 002a04b1fc..d52f971140 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md @@ -12,7 +12,7 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 **设置外壳协调有序步骤。** `settings.onboarding` 仍是根作用域 list,但 `ui-settings` 会把其中各条目的 id 和顺序投影到一个协调器中,并且只挂载第一个未完成的步骤。当前注册方会收到 `complete()` 和 `openSection(id)`;所有权转移前,不会挂载后续步骤。产品欢迎步骤的顺序为 `-100`,`ui-models` 则只保留顺序为 `0` 的 DeepSeek 条件式就绪状态与凭据跳转步骤。 -**不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 +**不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。通知说明会话遥测[默认禁用](2026-08-10-telemetry-default-off.md),并列出 `FEEDBACK_ONLY` 和 `FULL` 两种显式启用模式。 **loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authority;hostname 判定留在 connection 包内,其他客户端插件只消费服务状态,而不导入其实现。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。更新该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml index 6f61384fd8..5423340a17 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.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-31-web-telemetry-default-mount.md -2026-07-31-web-telemetry-default-mount.md: 3e20fa44666139778e85a8bd5b948ab2df14529b -2026-07-31-web-telemetry-default-mount.zh.md: 97b153255a1e64aba0a686faf1817b69467fb663 +2026-07-31-web-telemetry-default-mount.md: 93bb788bd48ab8c82bd013c8f844e01913f537e4 +2026-07-31-web-telemetry-default-mount.zh.md: d75d3e36f5e4b6c9fab146b28e301cfc3b11f8b6 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md index 3e20fa4466..93bb788bd4 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md @@ -10,23 +10,24 @@ The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry ## Decision -The shared `dsh` base (`apps/cli/config/base.cordis.yml`) mounts the `telemetry-otel` row by default with a baked-in production endpoint, so Web and headless report; the raw-config command also mounts it before applying its required deployment overlay. This is the **internal-testing deployment stance** — reporting is on when an endpoint exists, and users opt out through the environment. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving the backend's three-second shutdown deadline time to drain before the five-second launcher bound. +The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the `telemetry-otel` row with a baked-in production endpoint, so every profile has one consistent telemetry capability. The [default-off decision](2026-08-10-telemetry-default-off.md) keeps that row in `DISABLED` mode unless a deployment explicitly selects `FULL` or `FEEDBACK_ONLY`; the endpoint alone does not authorize reporting. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving an enabled backend's three-second shutdown deadline time to drain before the five-second launcher bound. | Ruling | Value | Rationale | |---|---|---| -| Mount surface | base.cordis.yml (raw config + Web + headless) | One deployment stance for every tree that loads the shared base; the raw overlay decides whether that deployment creates sessions | +| Mount surface | `packages/bundle/base/cordis.patch.yml` | One capability row for every profile that loads the shared base | +| Sharing mode | `DSH_TELEMETRY_MODE`, default `DISABLED`; explicit `FULL` or `FEEDBACK_ONLY` opts in | A fresh profile makes no telemetry network request, while internal deployments retain both upload policies | | Endpoint | `DSH_TELEMETRY_OTLP_URL`, default `https://harness-telemetry.deepseeksvc.com/v1/logs` | Internal collector; the env override serves local/dev runs | -| Opt-out switch | any non-empty `DSH_TELEMETRY_DISABLED` (including `0`/`false`) disables | A privacy switch prefers off-by-mistake over on-by-mistake; a row can only be disabled at AppCLIEntry's patch layer (config has no disable semantic, and the switch must precede the load-time `exporter.url` validation) | -| Cadence | `processor.scheduledDelayMillis: 10000` (10s/batch) | Streaming while the session runs, never exit-time-only; a crash loses at most the last unexported interval | +| Hard opt-out | any non-empty `DSH_TELEMETRY_DISABLED` (including `0`/`false`) disables the row | The launcher patch takes effect before load-time transport validation and overrides every configured mode | +| Cadence | `processor.scheduledDelayMillis: 10000` (10s/batch) in uploading modes | Streaming while the session runs, never exit-time-only; a crash loses at most the last unexported interval | | Exit-drain bound | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048` (== maxQueueSize) + `exportTimeoutMillis: 1500` + `shutdownTimeoutMillis: 3000` | Ordinary unreachable-collector failure releases in ~1s: timeoutMillis is the per-attempt socket timeout and retry deadline, while one queue-sized batch avoids sequential drain multiplication. The DSH-owned 3s outer bound covers the SDK's preceding unbounded `forceFlush()` wait when the transport Promise never obtains a socket. | | Compression | `compression: gzip` | Event bodies carry full content; cross-datacenter bandwidth | -| CI isolation | top-level `env: DSH_TELEMETRY_DISABLED: '1'` in all 8 GitHub workflows | Every CI channel that boots the web composition (e2e/snapshot/built smokes) must not stream test sessions to the production endpoint | +| CI isolation | top-level `env: DSH_TELEMETRY_DISABLED: '1'` in GitHub workflows | Defense in depth keeps test sessions local even when a job explicitly selects an uploading mode | -The keyless integration test `apps/cli/tests/telemetry-web.e2e.ts` pins the deployment-level behavior: an in-test OTLP collector plus a mock LLM server, a real `dsh web` boot, asserting ledger coverage, seq monotonicity, the first-of-step chunk projection, and the ops `shutdown` marker arriving through the SIGINT drain. +The base bundle test pins the shipped `DISABLED` mode expression, the backend suite pins that omitted mode constructs no transport, and the real Loader composition suite explicitly selects each uploading mode when it verifies OTLP delivery. ## Alternatives considered -**No default mount; deployments add the row themselves (continuing the SDK stance).** Rejected for this stage: this repo's web/headless composition IS the internal deployment, and default-on reporting is that deployment's product requirement; the SDK stance survives in the Service Definition packages (unmounted = nothing leaves). +**No default mount; deployments add the row themselves.** Rejected because the mounted `DISABLED` mode retains a local feedback warning and gives all profiles one patch target without authorizing any upload. **A config field instead of an env patch for the switch.** Infeasible: cordis rows have no config-level disable semantic, and `exporter.url` validation fails loud at plugin construction, so the switch must take effect before the Loader — AppCLIEntry's patch layer is the only seat. @@ -34,6 +35,6 @@ The keyless integration test `apps/cli/tests/telemetry-web.e2e.ts` pins the depl ## Consequences -- A developer running `dsh web` without a local collector POSTs to the production endpoint every 10s (silent failure when unreachable; no OTel diag logger is registered); local development sets `DSH_TELEMETRY_DISABLED=1` or points `DSH_TELEMETRY_OTLP_URL` locally. -- **No redaction rule is mounted yet**: exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `telemetry/record` rules first — the redaction rule, the remaining identity Resource attributes (hostname / surface; the anonymous user id shipped via the [anonymous-user-id Note](2026-07-31-telemetry-anonymous-user-id.md)), and the usage-metrics track are the explicit follow-ups of this decision. -- Test rigs reusing this tree (e.g. `apps/web/tests/scaffold.ts`) must explicitly disable the row, or fixture sessions stream to whatever collector the environment happens to name. +- A developer running `dsh web` without telemetry configuration makes no telemetry network request. An internal deployment sets `DSH_TELEMETRY_MODE` and may point `DSH_TELEMETRY_OTLP_URL` at another collector. +- **No redaction rule is mounted**: explicitly enabled exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `telemetry/record` rules first — the redaction rule, remaining identity Resource attributes, and usage metrics remain separate deployment work. The anonymous user id ships through the [anonymous-user-id Note](2026-07-31-telemetry-anonymous-user-id.md). +- Test rigs remain local by default; explicit uploading-mode tests provide their own collector and mode. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md index 97b153255a..d75d3e36f5 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md @@ -10,23 +10,24 @@ Status: implemented ## 决策 -`dsh` 共享 base(`apps/cli/config/base.cordis.yml`)默认挂载 `telemetry-otel` 行,内置生产 endpoint,因此 Web 与 headless 都会上报;原始配置命令也会先挂载该行,再应用其必需的部署 overlay。这是**内部测试期的部署立场**——有 endpoint 就上报,用户可通过环境变量退出。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md),在启动器 5 秒上限到期前,先给后端 3 秒关闭截止时间完成排空。 +共享 dsh 基础组合包(`packages/bundle/base/cordis.patch.yml`)挂载带有内置生产 endpoint 的 `telemetry-otel` 配置行,使每个 profile 都具有一致的遥测能力。[默认关闭决策](2026-08-10-telemetry-default-off.md)让该配置行保持 `DISABLED` 模式,除非部署方显式选择 `FULL` 或 `FEEDBACK_ONLY`;仅配置 endpoint 不构成上报授权。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md),在启动器 5 秒上限到期前,先给已启用的后端 3 秒关闭截止时间完成排空。 | 决策项 | 取值 | 理由 | |---|---|---| -| 挂载面 | base.cordis.yml(原始配置 + Web + headless) | 所有加载共享 base 的配置树采用同一个部署立场;原始配置 overlay 决定该部署是否创建会话 | +| 挂载面 | `packages/bundle/base/cordis.patch.yml` | 每个加载共享基础组合包的 profile 都使用同一个能力配置行 | +| 共享模式 | `DSH_TELEMETRY_MODE`,默认 `DISABLED`;显式设置 `FULL` 或 `FEEDBACK_ONLY` 即启用 | 新 profile 不发出遥测网络请求,内部部署仍可使用两种上传策略 | | endpoint | `DSH_TELEMETRY_OTLP_URL`,缺省 `https://harness-telemetry.deepseeksvc.com/v1/logs` | 内部 collector;env 覆盖供本地/联调 | -| 退出开关 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即关 | 隐私向开关取「宁关勿误开」;行级 disable 只能在 AppCLIEntry 的 patch 层做(config 无 disable 语义,且必须先于 `exporter.url` 的加载期校验生效) | -| 上报节奏 | `processor.scheduledDelayMillis: 10000`(10s/批) | 流式回流,非退出才报;崩溃至多丢失最后一个尚未导出间隔内的数据 | +| 硬性退出 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即禁用该配置行 | 启动器 patch 在加载期传输校验之前生效,并覆盖所有已配置模式 | +| 上报节奏 | 上传模式中为 `processor.scheduledDelayMillis: 10000`(10s/批) | 在会话运行期间流式上报,而非仅在退出时上报;崩溃至多丢失最后一个尚未导出间隔内的数据 | | 退出 drain 上界 | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048`(与 maxQueueSize 相等) + `exportTimeoutMillis: 1500` + `shutdownTimeoutMillis: 3000` | collector 不可达的常规故障会在约 1s 内放行:timeoutMillis 是单次 socket 超时与重试 deadline,使用与队列等大的单批可避免依次排空导致耗时倍增。由 DSH 管理的 3s 外层上限覆盖 SDK 先执行的无界 `forceFlush()` 等待,即传输 Promise 始终无法取得 socket 的情况。 | | 压缩 | `compression: gzip` | 事件 body 含全文,跨机房带宽 | -| CI 隔离 | 全部 8 个 GitHub 工作流顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | CI 启动 web 组合的所有通道(e2e/快照/已构建版本的冒烟测试)不得向生产 endpoint 泄测试会话 | +| CI 隔离 | GitHub 工作流顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | 即使 CI 任务显式选择上传模式,纵深防御也会让测试会话留在本地 | -集成测试 `apps/cli/tests/telemetry-web.e2e.ts`(keyless)钉住部署级行为:测试内 OTLP collector + mock LLM(大语言模型)服务器,真启动 `dsh web`,断言 ledger 覆盖、seq 单调、步骤首个分片投影、以及 SIGINT drain 后 ops `shutdown` 标记到达。 +基础组合包测试固定交付的 `DISABLED` 模式表达式,后端测试套件固定省略模式时不构造传输,真实 Loader 组合测试则在验证 OTLP 投递时显式选择每种上传模式。 ## 考虑过的替代方案 -**默认不挂载,部署方自行加行(SDK 立场的延续)。** 当前阶段不采用:本仓的 web/headless 组合就是内部部署本身,「上报默认开」是这个部署的产品要求;SDK 立场仍由 Service Definition 包保持(不挂 = 零外发)。 +**默认不挂载,部署方自行添加配置行。** 不采用:挂载的 `DISABLED` 模式会保留本地反馈警告,并为所有 profile 提供同一个 patch 目标,同时不授权任何上传。 **开关做成 config 字段而非 env patch。** 不可行:cordis 行没有 config 层的 disable 语义,且 `exporter.url` 校验在插件构造期 fail-loud,开关必须在 Loader 之前生效——AppCLIEntry patch 层是唯一落点。 @@ -34,6 +35,6 @@ Status: implemented ## 后果 -- 无本地 collector 的开发者跑 `dsh web` 会对生产 endpoint 每 10s 发一次 POST(联不通则静默失败,OTel diag logger 未注册);本地开发设 `DSH_TELEMETRY_DISABLED=1` 或 `DSH_TELEMETRY_OTLP_URL` 指本地。 -- **当前尚未挂载任何脱敏规则**:导出即原始捕获副本(用户/助手消息全文、工具参数与工具结果、系统提示词、`session.cwd` 本地路径)。跨信任边界前必须挂 `telemetry/record` 规则——脱敏规则、其余身份 Resource 属性(hostname/surface;匿名 user id 已由[匿名用户 id Note](2026-07-31-telemetry-anonymous-user-id.md)落地)、使用数据 metrics 轨是本决策明确的后续工作。 -- 复用这棵树的测试载具(如 `apps/web/tests/scaffold.ts`)须显式关停该行,否则 fixture(测试前置数据)会话会流向 env 里碰巧存在的 collector。 +- 开发者运行没有遥测配置的 `dsh web` 时,不会发出遥测网络请求。内部部署需设置 `DSH_TELEMETRY_MODE`,并可让 `DSH_TELEMETRY_OTLP_URL` 指向其他 collector。 +- **没有挂载任何脱敏规则**:显式启用的导出即原始捕获副本(用户/助手消息全文、工具参数与工具结果、系统提示词、`session.cwd` 本地路径)。跨信任边界前必须先挂载 `telemetry/record` 规则;脱敏规则、其余身份 Resource 属性和使用情况指标仍是独立的部署工作。匿名 user id 由[匿名 user id Note](2026-07-31-telemetry-anonymous-user-id.md)交付。 +- 测试载具默认将数据留在本地;显式启用上传模式的测试提供自己的 collector 和模式。 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml index 63b0efa3aa..ba99863f03 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.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-08-05-feedback-gated-session-telemetry.md -2026-08-05-feedback-gated-session-telemetry.md: 7d923a7e4cf61e8d1119187564b87e4cbb2065b7 -2026-08-05-feedback-gated-session-telemetry.zh.md: e2fa6f9561de6e7295cbf860527c7d7915fec0bf +2026-08-05-feedback-gated-session-telemetry.md: 524eb9defa9981c6d535ff68012d206b9c0cff94 +2026-08-05-feedback-gated-session-telemetry.zh.md: 557e3681f627931560d19172ddd8fa69a2984af1 diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md index 7d923a7e4c..524eb9defa 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md @@ -6,15 +6,15 @@ English | [中文](2026-08-05-feedback-gated-session-telemetry.zh.md) ## Problem -Session telemetry originally has one mounted behavior: every accepted record enters the reporting backend immediately. Deployments need two stricter policies without replacing the plugin: hold a session's telemetry unless its user records feedback, or disable reporting while still explaining what happens to feedback. The policy must preserve the existing full-export default and the telemetry seam's redaction-before-backend boundary. +Session telemetry originally has one mounted behavior: every accepted record enters the reporting backend immediately. Deployments need two stricter policies without replacing the plugin: hold a session's telemetry unless its user records feedback, or disable reporting while still explaining what happens to feedback. The policy must preserve the telemetry seam's redaction-before-backend boundary. ## Decision `@deepseek-ai/dsh-session-telemetry-otel` exposes the string-valued `TelemetryMode` enum to TypeScript callers and accepts the same three uppercase `mode` values in serialized configuration: -- `FULL` is the default and preserves immediate delivery to the configured OTel pipeline. +- `FULL` explicitly selects immediate delivery to the configured OTel pipeline. - `FEEDBACK_ONLY` reads the canonical session log when `feedback/record` is appended and hands over the unreleased prefix through that exact event. Records appended after that boundary remain local until another feedback event. -- `DISABLED` constructs no exporter, processor, or logger provider. A `feedback/record` listener prints that nothing is shared and the feedback remains local. +- `DISABLED` is the [default](2026-08-10-telemetry-default-off.md), constructs no exporter, processor, or logger provider, and prints that nothing is shared and the feedback remains local when it observes `feedback/record`. The generic telemetry coordinator owns `live` and `on-demand` capture. Live capture projects, clones, redacts, and hands each event to the backend on the session firehose. On-demand capture registers no continuous capture listeners; `captureSession(session, throughSeq)` reads the canonical log from the handoff cursor through an inclusive boundary, then projects, clones, redacts, and hands over that prefix. The cursor advances only for handed-over records. The [buffer-free replay decision](../simplification/2026-08-06-buffer-free-feedback-telemetry.md) owns why the on-demand path uses the canonical log instead of copied records. @@ -32,4 +32,4 @@ Mode resolution is a closed, fail-before-setup check: an unknown direct-construc ## Consequences -`FULL` remains source- and wire-compatible with the original default. `FEEDBACK_ONLY` adds no telemetry-owned per-event buffer before feedback; direct service calls and non-canonical feedback events upload nothing, and a crash before feedback uploads nothing from that prefix. Replay applies the redaction policy mounted when feedback is recorded and excludes operational records that do not exist in the canonical log. Feedback-only streams therefore carry neither `agent-error` nor `shutdown` records, and shutdown absence is not a crash signal. Each later feedback captures the suffix accumulated since the previous boundary. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log. +`FULL` retains the original source and wire behavior as an explicit opt-in. `FEEDBACK_ONLY` adds no telemetry-owned per-event buffer before feedback; direct service calls and non-canonical feedback events upload nothing, and a crash before feedback uploads nothing from that prefix. Replay applies the redaction policy mounted when feedback is recorded and excludes operational records that do not exist in the canonical log. Feedback-only streams therefore carry neither `agent-error` nor `shutdown` records, and shutdown absence is not a crash signal. Each later feedback captures the suffix accumulated since the previous boundary. `DISABLED` can omit `exporter.url`, does no reporting work, and keeps feedback only in the canonical session log. diff --git a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md index e2fa6f9561..557e3681f6 100644 --- a/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -会话遥测原本只有一种已挂载行为:每条已接受记录都立即进入上报后端。部署方需要两种更严格的策略,且不替换插件:只有用户记录反馈时才释放该会话的遥测,或禁用上报并仍向用户说明反馈的去向。该策略必须保留现有的全量导出默认值,以及遥测 seam 在记录抵达后端之前脱敏的边界。 +会话遥测原本只有一种已挂载行为:每条已接受记录都立即进入上报后端。部署方需要两种更严格的策略,且不替换插件:只有用户记录反馈时才释放该会话的遥测,或禁用上报并仍向用户说明反馈的去向。该策略必须保留遥测 seam 在记录抵达后端之前脱敏的边界。 ## 决策 `@deepseek-ai/dsh-session-telemetry-otel` 向 TypeScript 调用方公开以字符串为值的 `TelemetryMode` 枚举,并在序列化配置中接受相同的三个大写 `mode` 值: -- `FULL` 是默认值,保留向已配置 OTel 流水线的即时投递。 +- `FULL` 显式选择向已配置 OTel 流水线即时投递。 - `FEEDBACK_ONLY` 在追加 `feedback/record` 时读取权威会话日志,并交接截至该事件的未释放前缀。该边界后追加的记录会留在本地,直到另一个反馈事件。 -- `DISABLED` 不构造导出器、处理器或日志提供方。`feedback/record` 监听器会输出警告,说明什么都不会共享,且反馈仍留在本地。 +- `DISABLED` 是[默认值](2026-08-10-telemetry-default-off.md),不构造导出器、处理器或日志提供方,并在观察到 `feedback/record` 时输出警告,说明什么都不会共享,且反馈仍留在本地。 通用遥测协调器拥有 `live` 与 `on-demand` 捕获。实时捕获在会话 firehose 上投影、深拷贝、脱敏每个事件,并将其交给后端。按需捕获不注册持续捕获监听器;`captureSession(session, throughSeq)` 从 handoff 游标起读取权威日志,直至含边界的指定序列号,然后投影、深拷贝、脱敏并交接该前缀。游标只为已交接记录推进。[无缓冲回放决策](../simplification/2026-08-06-buffer-free-feedback-telemetry.md)说明了按需路径为何使用权威日志而非记录副本。 @@ -32,4 +32,4 @@ Status: implemented ## 后果 -`FULL` 与原有默认值保持源码及协议兼容。`FEEDBACK_ONLY` 在反馈前不增加遥测自有的逐事件缓冲;直接服务调用与非权威反馈事件均不上传任何内容,且反馈前发生崩溃时,该前缀也不上传任何内容。回放使用记录反馈时挂载的脱敏策略,并排除权威日志中不存在的运维记录。因此,仅反馈的流既不携带 `agent-error` 记录,也不携带 `shutdown` 记录,而缺少 shutdown 不是崩溃信号。每个后续反馈都会捕获从上一个边界起累积的后缀。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。 +`FULL` 作为显式启用模式保留原有的源码与协议行为。`FEEDBACK_ONLY` 在反馈前不增加遥测自有的逐事件缓冲;直接服务调用与非权威反馈事件均不上传任何内容,且反馈前发生崩溃时,该前缀也不上传任何内容。回放使用记录反馈时挂载的脱敏策略,并排除权威日志中不存在的运维记录。因此,仅反馈的流既不携带 `agent-error` 记录,也不携带 `shutdown` 记录,而缺少 shutdown 不是崩溃信号。每个后续反馈都会捕获从上一个边界起累积的后缀。`DISABLED` 可省略 `exporter.url`,不执行任何上报工作,并仅在权威会话日志中保留反馈。 diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml new file mode 100644 index 0000000000..4817fe52bd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.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-08-10-telemetry-default-off.md +2026-08-10-telemetry-default-off.md: e4a075fb6eee606057ffe1b953af525169d9cb80 +2026-08-10-telemetry-default-off.zh.md: 4f3b27e76fcd895269b0934f3623ce355e377913 diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md new file mode 100644 index 0000000000..e4a075fb6e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -0,0 +1,31 @@ +# Agent Note: Telemetry requires explicit opt-in + +Status: implemented + +English | [中文](2026-08-10-telemetry-default-off.zh.md) + +## Problem + +DeepSeek Harness has two outbound telemetry feeds. The session OTel backend could export complete session content, tool data, prompts, and workspace paths when its mode was omitted, while the dsh-sdk launcher feed reported by default. A fresh installation therefore permitted outbound reporting without a positive deployment choice. + +## Decision + +Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. + +The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. This rule supersedes only the default-on launcher consent in the [SDK follow-up proposal](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md); its other capabilities remain proposed. + +The versioned Web welcome notice states that Session Log upload is off by default and names `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` as the two opt-in choices. Its version changes with that material privacy statement so every profile acknowledges the current copy. + +## Alternatives considered + +**Keep opt-out defaults and improve disclosure.** Rejected because disclosure does not make a missing configuration a positive authorization to send data, especially when session telemetry can contain complete local content. + +**Default session telemetry to `FEEDBACK_ONLY`.** Rejected because recording feedback would still trigger an upload without a deployment explicitly enabling outbound reporting. The default must keep both the session and its feedback local. + +**Add project-level consent markers.** Rejected because `DSH_TELEMETRY_MODE` already expresses consent for both feeds; another configuration entry would create conflicting settings and require launcher-specific parsing. + +**Remove both telemetry implementations.** Rejected because internal deployments still need explicit `FULL` and feedback-gated reporting, and the launcher feed remains useful under `FULL`. + +## Consequences + +Fresh profiles and projects make no telemetry network request. Internal deployments select one mode for both feeds: `FEEDBACK_ONLY` permits only feedback-triggered Session Log sharing, while `FULL` also enables launcher reporting. The existing hard opt-out remains effective, and uploading modes retain their endpoint validation, redaction responsibility, batching, and shutdown behavior. diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md new file mode 100644 index 0000000000..4f3b27e76f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 遥测必须显式启用 + +Status: implemented + +[English](2026-08-10-telemetry-default-off.md) | 中文 + +## 问题 + +DeepSeek Harness 有两路出站遥测数据流。会话 OTel 后端在省略 `mode` 时可能导出完整会话内容、工具数据、提示词和工作区路径,而 dsh-sdk 启动器数据流默认上报。因此,全新安装无需部署方明确选择便允许向外上报。 + +## 决策 + +两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.md)继续负责 endpoint、批处理节奏和退出排空设置。 + +dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。此规则仅取代 [SDK 后续功能提案](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md)中启动器默认允许上报的规则;其余能力仍处于提案状态。 + +带版本的 Web 欢迎通知说明会话日志上传默认关闭,并将 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 列为两种显式启用选项。其版本随这项重要的隐私声明一同变更,使每个 profile 都确认当前文案。 + +## 考虑过的替代方案 + +**保留默认退出机制并改进披露。** 不采用:披露不能让缺少配置构成发送数据的明确授权,尤其是会话遥测可能包含完整的本地内容。 + +**将会话遥测默认设为 `FEEDBACK_ONLY`。** 不采用:即使部署方没有显式启用向外上报,记录反馈仍会触发上传。默认值必须让会话及其反馈都留在本地。 + +**添加项目级授权标记。** 不采用:`DSH_TELEMETRY_MODE` 已能表达两路数据流的授权;另一个配置项会产生冲突设置,并需要启动器专用的解析逻辑。 + +**删除两种遥测实现。** 不采用:内部部署仍需要显式启用 `FULL` 与反馈触发的上报;在 `FULL` 下,启动器数据流也仍有用。 + +## 后果 + +全新 profile 和项目不发出任何遥测网络请求。内部部署为两路数据流选择一个模式:`FEEDBACK_ONLY` 只允许由反馈触发的 Session Log 共享,`FULL` 还会启用启动器上报。现有硬性退出继续生效,上传模式也保留 endpoint 校验、脱敏责任、批处理和关闭行为。 diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml index 7084931b1d..eb5cf10d77 100644 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.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/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md -2026-07-17-sdk-follow-up-capabilities.md: f14d46a61f5fd3e64067441c2f8340cf94746a79 -2026-07-17-sdk-follow-up-capabilities.zh.md: 2e5efba340d503d2445e408bfc43ee0d6c6bec3c +2026-07-17-sdk-follow-up-capabilities.md: 3f264bab6785458aaab14f22bfbba46363d729a3 +2026-07-17-sdk-follow-up-capabilities.zh.md: 4a4308c0bf5f1d28f1d125f8d35bfc6dabc944f7 diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md index f14d46a61f..3f264bab67 100644 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md +++ b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md @@ -55,13 +55,13 @@ This proposal concerns dependencies of developer-owned SDK projects. Standalone Telemetry wraps the `create-sdk` initializer and the `dsh-sdk` launcher command lifecycle because project initialization, plugin creation, and build do not reliably boot Cordis. One event records the command name, duration, success, a random per-user anonymous identifier, and redacted `cordis.yml` and `package.json` text when those project files are eligible. -Reporting is enabled unless a present telemetry config entry is explicitly disabled. `DO_NOT_TRACK` and CI deny reporting regardless of project configuration. A missing `cordis.yml` does not itself deny the event, but `package.json` content is included only when `cordis.yml` establishes that the directory is an SDK project. +The [explicit-opt-in decision](../../implemented/feature/2026-08-10-telemetry-default-off.md) supersedes this proposal's default-on consent rule. Launcher reporting reads the shared `DSH_TELEMETRY_MODE` and is enabled only under `FULL`; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny reporting. `package.json` content is included only when `cordis.yml` establishes that the directory is an SDK project. ### Safety and delivery The payload builder never reads `.env`. It redacts secret-shaped keys and values, known token forms, PEM blocks, URL credentials, and high-entropy opaque strings in the two eligible text files. Redaction is a safety backstop rather than a guarantee; SDK projects must keep credentials in `.env`. -The reporter uses a fixed endpoint and resolves every send path without throwing. Command dispatch records success or failure in a `finally` path, starts reporting after the command outcome is known, and drains within a bounded interval. Consent parsing, payload construction, storage, or network failures are swallowed only at this telemetry boundary and never alter the command's exit code. +The reporter uses a fixed endpoint and resolves every send path without throwing. Command dispatch records success or failure in a `finally` path, starts reporting after the command outcome is known, and drains within a bounded interval. Consent resolution, payload construction, storage, or network failures are swallowed only at this telemetry boundary and never alter the command's exit code. ## Interactive workflow testing @@ -72,7 +72,6 @@ One or two optional real-PTY smoke tests may cover the shipped binary and TTY gu ## Deferred work - Extend the headless create specification to express local `plugin` or `tool` scaffolding instead of defaulting that interactive choice to none. -- Expose the telemetry opt-out in create and config while preserving the consent representation in which only a disabled telemetry entry is written. - Define whether GitHub source dependencies must be prebuilt or may run package-manager-controlled preparation scripts, and surface the policy before installation. - Replace the telemetry package's `.invalid` endpoint placeholder with the production endpoint before release. @@ -99,7 +98,7 @@ One or two optional real-PTY smoke tests may cover the shipped binary and TTY gu - Create runs without a TTY from a complete structured input, emits only NDJSON on stdout under `--json`, and reports missing required input as `action-required` without writing a partial project. - Create and config resolve the same feature-plan contract through the shared question, feature-configuration, and project-editing code paths. - `dsh-sdk create ` uses the selected project package manager, mounts the dependency name that operation actually added, and fails loudly when no new dependency can be identified. -- The initializer and every `dsh-sdk` command reach one best-effort telemetry completion path; an explicit disabled entry, `DO_NOT_TRACK`, or CI prevents delivery, and telemetry failures never change the command result. +- The initializer and every `dsh-sdk` command reach one best-effort telemetry completion path; only `DSH_TELEMETRY_MODE=FULL` permits delivery, and telemetry failures never change the command result. - Telemetry never reads `.env`, withholds unrelated `package.json` content when no `cordis.yml` exists, redacts both eligible text payloads, and uses an identifier unrelated to git metadata. - Interactive tests cover create and config decisions through injected interaction and assert committed project files; any real-PTY coverage remains a narrow smoke layer. - The agent skill documents the public structured-input and event contracts without depending on private package exports. @@ -107,7 +106,7 @@ One or two optional real-PTY smoke tests may cover the shipped binary and TTY gu ## Risks - Full redacted `cordis.yml` and `package.json` text still reveals plugin and dependency names, URLs, paths, and configuration values to the endpoint operator, and heuristic redaction can miss a secret. -- Default-on reporting may surprise developers when no telemetry entry exists; the CLI must make the opt-out discoverable before release. +- `FULL` opts into both complete Session Log sharing and launcher project-text reporting; deployments cannot enable those feeds independently. - A package-manager add can change `package.json`, the lockfile, and installed files before `ProjectEditSession` mounts the plugin, so a later mount failure can leave dependency changes that require manual recovery. - GitHub dependencies may execute preparation or lifecycle code according to package-manager policy; an unresolved build policy is a supply-chain and reproducibility risk. - Injected prompt tests do not prove raw-mode, signal, or repaint behavior in a real terminal; the optional smoke layer must cover only those residual contracts. @@ -116,5 +115,4 @@ One or two optional real-PTY smoke tests may cover the shipped binary and TTY gu - [Vercel Eve](https://github.com/vercel/eve) and [Vercel Labs Skills](https://github.com/vercel-labs/skills) for the distinction between a headless initializer and skill distribution. - [npm package specifications](https://docs.npmjs.com/cli/v11/using-npm/package-spec), [pnpm add](https://pnpm.io/cli/add), and [Yarn add](https://yarnpkg.com/cli/add) for package-manager-native sources. -- [`DO_NOT_TRACK`](https://donottrack.sh/) for the environment-level opt-out convention. - [Clack](https://github.com/bombshell-dev/clack) and [Vitest snapshots](https://vitest.dev/guide/snapshot) for injected prompts and generated-file assertions. diff --git a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md index 2e5efba340..4a4308c0bf 100644 --- a/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md +++ b/.agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.zh.md @@ -55,7 +55,7 @@ Create 和 config 使用相同的功能计划形状。create 通过上述命令 遥测包住 `create-sdk` 初始化命令与 `dsh-sdk` launcher 的命令生命周期,因为工程初始化、插件创建和 build 都不会稳定地启动 Cordis。每个事件记录命令名、时长、成败、随机生成的用户级匿名标识符,以及符合条件时经过脱敏的 `cordis.yml` 与 `package.json` 文本。 -除非当前存在的遥测配置项被明确禁用,否则允许上报。`DO_NOT_TRACK` 和 CI 无论工程配置如何都禁止上报。缺少 `cordis.yml` 本身不会禁止事件,但只有 `cordis.yml` 能证明目录是 SDK 工程时,遥测内容才包含 `package.json` 文本。 +[显式启用决策](../../implemented/feature/2026-08-10-telemetry-default-off.md)取代了本提案默认允许上报的许可规则。启动器上报读取共享的 `DSH_TELEMETRY_MODE`,只在 `FULL` 下启用;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝上报。只有 `cordis.yml` 能证明目录是 SDK 工程时,遥测内容才包含 `package.json` 文本。 ### 安全与传输 @@ -72,7 +72,6 @@ Create 和 config 测试向现有工作流注入 `PromptPort` 和脚本化输入 ## 延后工作 - 扩展 headless create 规格,使其能表达本地 `plugin` 或 `tool` 脚手架,而不是把该交互选择默认为 none。 -- 在 create 和 config 中公开遥测关闭选项,同时保留只有禁用时才写入遥测配置项的上报许可表示。 - 明确 GitHub 来源依赖必须预先构建,还是允许运行由包管理器控制的 preparation script(准备脚本),并在安装前向用户展示该策略。 - 发布前把遥测包中的 `.invalid` endpoint 占位符替换为生产端点。 @@ -99,7 +98,7 @@ Create 和 config 测试向现有工作流注入 `PromptPort` 和脚本化输入 - Create 能依据完整结构化输入在没有 TTY 时运行;使用 `--json` 时 stdout 只输出 NDJSON;缺少必答输入时通过 `action-required` 报告,且不写入部分工程。 - Create 和 config 通过共享的问题、功能配置和工程编辑代码路径解析相同的功能计划约定。 - `dsh-sdk create ` 使用工程选定的包管理器,挂载该操作实际新增的依赖名;无法识别新增依赖时快速失败。 -- 初始化命令与每个 `dsh-sdk` 命令都进入同一条尽力而为的遥测收尾路径;明确禁用的配置项、`DO_NOT_TRACK` 或 CI 会阻止传输,遥测失败绝不改变命令结果。 +- 初始化命令与每个 `dsh-sdk` 命令都进入同一条尽力而为的遥测收尾路径;只有 `DSH_TELEMETRY_MODE=FULL` 才允许传输,遥测失败绝不改变命令结果。 - 遥测绝不读取 `.env`;没有 `cordis.yml` 时不发送无关的 `package.json` 内容;两个符合条件的文本都经过脱敏;匿名标识符与 git 元数据无关。 - 交互测试通过注入交互覆盖 create 和 config 决策,并断言已提交的工程文件;真实 PTY 覆盖只作为窄范围冒烟层。 - Agent skill 说明公开的结构化输入与事件约定,不依赖包的私有导出。 @@ -107,7 +106,7 @@ Create 和 config 测试向现有工作流注入 `PromptPort` 和脚本化输入 ## 风险 - 即使经过脱敏,完整的 `cordis.yml` 与 `package.json` 文本仍会向 endpoint 运营方暴露插件名、依赖名、URL、路径和配置值;启发式脱敏也可能漏掉 secret。 -- 没有遥测配置项时默认上报可能让开发者意外;发布前 CLI 必须让关闭方法易于发现。 +- `FULL` 会同时启用完整 Session Log 共享和启动器工程文本上报;部署方无法独立启用这两路数据流。 - 在 `ProjectEditSession` 挂载插件前,包管理器的 add 操作已经可能修改 `package.json`、lockfile 和安装文件;后续挂载失败会留下需要手工恢复的依赖改动。 - GitHub 依赖可能按包管理器策略执行 preparation 或 lifecycle script;尚未解决的构建策略会带来供应链与可复现性风险。 - 注入提示词交互的测试无法证明真实终端中的 raw mode、signal 或重绘行为;可选冒烟层只应覆盖这些残余约定。 @@ -116,5 +115,4 @@ Create 和 config 测试向现有工作流注入 `PromptPort` 和脚本化输入 - [Vercel Eve](https://github.com/vercel/eve) 与 [Vercel Labs Skills](https://github.com/vercel-labs/skills) 用于区分 headless 初始化命令与 skill 分发。 - [npm package specifications](https://docs.npmjs.com/cli/v11/using-npm/package-spec)、[pnpm add](https://pnpm.io/cli/add)和 [Yarn add](https://yarnpkg.com/cli/add)说明包管理器原生来源。 -- [`DO_NOT_TRACK`](https://donottrack.sh/)定义环境级关闭约定。 - [Clack](https://github.com/bombshell-dev/clack) 和 [Vitest snapshots](https://vitest.dev/guide/snapshot) 说明注入提示词交互与生成文件断言。 diff --git a/README.i18n.yaml b/README.i18n.yaml index 35cb82e776..9f0c979e45 100644 --- a/README.i18n.yaml +++ b/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 -README.md: 3174630d021b3868986d6ad9989d257fe8ac29fb -README.zh.md: 377ea6372a9a531c1400d08b0ef33792b452dc65 +README.md: 65b51f60bfaafff71d7eb4a8f34a57f3dd3ed12f +README.zh.md: 73c17b84ff3ed90e0d1a0ed94454c405e2e0c8f2 diff --git a/README.md b/README.md index 3174630d02..65b51f60bf 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ It uses an architecture where **everything is a plugin**. DeepSeek Harness is under internal testing. Features and interfaces may change. -The internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group. +Session Logs stay local by default. Set `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` to share a Session Log only when submitting feedback, or `DSH_TELEMETRY_MODE=FULL` to upload continuously. Send feedback through the internal WeChat group. ## Install diff --git a/README.zh.md b/README.zh.md index 377ea6372a..73c17b84ff 100644 --- a/README.zh.md +++ b/README.zh.md @@ -10,7 +10,7 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 DeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。 -为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。 +Session Log 默认留在本地。设置 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 可仅在提交反馈时共享 Session Log,设置 `DSH_TELEMETRY_MODE=FULL` 可持续上传。请通过内部企业微信群反馈问题和建议。 ## 安装 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 6aa39844d0..baa822d2eb 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: 7f0bd7b0bd50482b3ba7ee95adf6aaf1defb6d28 -README.zh.md: 55f23c5644b8b063ef12fb56fa587ff5d2eb21a2 +README.md: ec907fa7a497e795a760cdf251054020d72f9d15 +README.zh.md: dea19856eb8618614f41c6ba7a838efbf545ba4c diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 7f0bd7b0bd..ec907fa7a4 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -65,9 +65,9 @@ New sessions default to the `workspace-write` permission preset. Bash and filesy ## Shared deployment behavior -The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. +The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, and disabled session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. -Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision. +Session telemetry stays local by default. `DSH_TELEMETRY_MODE=FULL` streams every projected session event as OTLP/HTTP logs, while `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` uploads a session-log suffix only when feedback is recorded. `DSH_TELEMETRY_OTLP_URL` selects another collector, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. The shipped base has no telemetry redaction rule, so explicitly enabled exports can contain message text, tool arguments and results, and workspace paths; the [default-off Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md) owns that deployment decision. Install external plugin bundles through `dsh plugin --profile add `. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 55f23c5644..dea19856eb 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -65,9 +65,9 @@ dsh web --dump-config ## 共享部署行为 -基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 +基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和已禁用的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 -会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。 +会话遥测默认留在本地。`DSH_TELEMETRY_MODE=FULL` 将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 则仅在记录反馈时上传会话日志后缀。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector,任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的硬性退出开关。随附基础配置没有遥测脱敏规则,因此显式启用的导出可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[默认关闭 Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md)负责。 通过 `dsh plugin --profile add ` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index e4a719379e..b312589f27 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -77,7 +77,7 @@ export const PROFILE_ROOT_FILENAME = 'cordis.yml' * switch set. * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset). * @param hasRow - whether the composition carries the telemetry row. - * @returns the disable patch, or `undefined` when telemetry stays enabled or is not mounted. + * @returns the disable patch, or `undefined` when no hard-disable patch is required. */ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined { if ((disabledEnv ?? '') === '' || !hasRow) return undefined diff --git a/apps/cli/tests/telemetry-switch.spec.ts b/apps/cli/tests/telemetry-switch.spec.ts index 0f44819564..efb36fc2d6 100644 --- a/apps/cli/tests/telemetry-switch.spec.ts +++ b/apps/cli/tests/telemetry-switch.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { resolveTelemetryPatch } from '../src/profile-boot.ts' describe('resolveTelemetryPatch', () => { - it('keeps telemetry enabled when the switch is unset or empty', () => { + it('preserves the configured telemetry mode when the hard-disable switch is unset or empty', () => { expect(resolveTelemetryPatch(undefined, true)).toBeUndefined() expect(resolveTelemetryPatch('', true)).toBeUndefined() }) diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md index 33e3d79fce..27e1935b23 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md @@ -3,7 +3,7 @@ - paragraph: 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 - blockquote: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。 - paragraph: - - text: 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外, + - text: 内测版本默认不会上传 Session Log。如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY;如需持续上传,可以设置 DSH_TELEMETRY_MODE=FULL。另外, - strong: 如果您有任何反馈与建议,请在企业微信群中留言告诉我们 - text: 。每一条反馈,都会帮助我们把它打磨得更好。 - button "继续" diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 02e95f011e..99bd4e7a55 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 10078dea5221cfe76b1c028ef83216f5a4575940 -config-catalog.zh.md: 38ed9f94bee9b0c11f5ea4dad4ba48532cce6450 +config-catalog.md: 8fb3f7fdef7d3fe46d462e4cf85de137f7d5fa56 +config-catalog.zh.md: 434f627265dd6181326dbc6d83c95f032c4bb22f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 10078dea52..8fb3f7fdef 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1529,7 +1529,7 @@ Requires: `sessions` * and shutdown deadline at plugin load; `DISABLED` reads neither. */ export interface Config { - /** Sharing policy; defaults to immediate `FULL` delivery. */ + /** Sharing policy; defaults to local-only `DISABLED` behavior. */ mode?: TelemetryMode /** * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 38ed9f94be..434f627265 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1531,7 +1531,7 @@ export interface Config { * and shutdown deadline at plugin load; `DISABLED` reads neither. */ export interface Config { - /** Sharing policy; defaults to immediate `FULL` delivery. */ + /** Sharing policy; defaults to local-only `DISABLED` behavior. */ mode?: TelemetryMode /** * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 66612f70fd..51a9a35ca6 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -120,14 +120,12 @@ - id: session-projection name: '@deepseek-ai/dsh-session-projection' - # Session telemetry, on for every dsh mode: mirrors every session-log - # event (assistant/chunk projected to first-of-step) plus ops markers onto - # OTLP/HTTP log records, streaming on the batch processor's cadence - # (10s/batch here) — not at exit; a crash loses at most the last unexported - # interval. No telemetry/record redaction rule is mounted yet, so exports - # are the raw captured copy; the deployment stance, env seams, and - # follow-ups are pinned in the web-telemetry-default-mount Agent Note. - # DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty + # Session telemetry is mounted but disabled by default. DSH_TELEMETRY_MODE + # explicitly opts into FULL or FEEDBACK_ONLY reporting; uploading mirrors + # session-log records onto OTLP/HTTP logs with no telemetry/record redaction + # rule, so exports are the raw captured copy. The deployment stance, env + # seams, and follow-ups are pinned in the default-off Agent Note. + # DSH_TELEMETRY_OTLP_URL overrides the production endpoint. A non-empty # DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the # process out (the launchers patch the row disabled; config cannot disable # a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid, @@ -144,6 +142,7 @@ - id: telemetry-otel name: '@deepseek-ai/dsh-session-telemetry-otel' config: + mode: !!js process.env.DSH_TELEMETRY_MODE || 'DISABLED' shutdownTimeoutMillis: 3000 exporter: url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs' diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 2da84a0931..cf38af88ce 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -23,11 +23,14 @@ describe('dsh-base bundle', () => { ) expect(Array.isArray(parsed)).toBe(true) // The base layer is one insert list over the empty profile root. - const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap( + const rows = (parsed as { insert?: { id?: string; config?: Record }[] }[]).flatMap( patch => patch.insert ?? [], ) expect(rows.length).toBeGreaterThan(50) expect(rows.some(row => row.id === 'agent-loop')).toBe(true) + expect(rows.find(row => row.id === 'telemetry-otel')?.config?.['mode']).toEqual({ + __jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'", + }) }) it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => { diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 8d7a5f1d4b..3366f440bb 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: ab27e073dc76335efc619f56365d1705007f7ef2 -README.zh.md: 16ff5604bee5425569b783e27a29699344f630e3 +README.md: 4fa8f0a854285e42fec415e4bcd7182dde745e2a +README.zh.md: e923da7ce27d5bc6a25ee44269acfd68aeb74983 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index ab27e073dc..4fa8f0a854 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -6,7 +6,7 @@ Settings ownerless-copy and product-onboarding plugin: registers everything on t A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read. -`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. +`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice states that session telemetry is disabled by default and names the `FEEDBACK_ONLY` and `FULL` opt-in modes. ## Model Experience diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 16ff5604be..e923da7ce2 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -6,7 +6,7 @@ 回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权设置读取。 -`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在用户设置 seam 中注册 `ui-onboarding`。回环浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非回环浏览器不能访问受保护的设置 API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 +`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在用户设置 seam 中注册 `ui-onboarding`。回环浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非回环浏览器不能访问受保护的设置 API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知说明会话遥测默认禁用,并列出 `FEEDBACK_ONLY` 和 `FULL` 两种显式启用模式。 ## 模型体验 diff --git a/packages/client/ui-settings-general/src/onboarding-copy.ts b/packages/client/ui-settings-general/src/onboarding-copy.ts index 061fc06872..e38ff3203d 100644 --- a/packages/client/ui-settings-general/src/onboarding-copy.ts +++ b/packages/client/ui-settings-general/src/onboarding-copy.ts @@ -8,7 +8,7 @@ export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion' * Bump only when the notice changes materially and every user should see it * again. The acknowledgement is compared for exact equality. */ -export const WELCOME_NOTICE_VERSION = '2026-07-30.7' +export const WELCOME_NOTICE_VERSION = '2026-08-10.1' /** The complete editable welcome notice in both supported GUI locales. */ export const WELCOME_NOTICE_COPY = { @@ -17,7 +17,7 @@ export const WELCOME_NOTICE_COPY = { paragraphs: [ '感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。', '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。', - '为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', + '内测版本默认不会上传 Session Log。如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY;如需持续上传,可以设置 DSH_TELEMETRY_MODE=FULL。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', ], feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们', continueLabel: '继续', @@ -27,7 +27,7 @@ export const WELCOME_NOTICE_COPY = { paragraphs: [ '感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。', '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。', - '为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', + '内测版本默认不会上传 Session Log。如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY;如需持续上传,可以设置 DSH_TELEMETRY_MODE=FULL。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', ], feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们', continueLabel: '继续', diff --git a/packages/scaffold/scripts/src/telemetry.ts b/packages/scaffold/scripts/src/telemetry.ts index d739a7164b..193cdf1fc7 100644 --- a/packages/scaffold/scripts/src/telemetry.ts +++ b/packages/scaffold/scripts/src/telemetry.ts @@ -7,9 +7,9 @@ */ import { - ConsentResolver, TelemetryReporter, buildTelemetryPayload, + resolveTelemetryConsent, type ConsentDecision, } from '@deepseek-ai/dsh-telemetry' @@ -17,7 +17,7 @@ import { export interface CommandTelemetryEvent { /** The dsh-sdk command that ran. */ command: string - /** Project directory whose consent, `cordis.yml`, and `package.json` are read. */ + /** Project directory whose `cordis.yml` and `package.json` may be reported. */ cwd: string /** Wall-clock duration in milliseconds. */ durationMs: number @@ -27,12 +27,12 @@ export interface CommandTelemetryEvent { /** Injectable consent and delivery hooks for tests. */ export interface CommandTelemetryDeps { - resolve?: (cwd: string) => Promise + resolve?: () => ConsentDecision | Promise reporter?: Pick } /** - * Resolve consent for the project and, when allowed, assemble and send one + * Resolve the shared telemetry mode and, when allowed, assemble and send one * telemetry event, draining in-flight sends before returning. Swallows every * error so telemetry can never change a command's result. * @param event - the command lifecycle facts. @@ -43,9 +43,8 @@ export async function reportCommandTelemetry( deps: CommandTelemetryDeps = {}, ): Promise { try { - /* v8 ignore next -- the production ConsentResolver is exercised by the built-bin smoke */ - const resolve = deps.resolve ?? (cwd => new ConsentResolver().resolve(cwd)) - const consent = await resolve(event.cwd) + /* v8 ignore next -- the production resolver is exercised by its owning tests */ + const consent = await (deps.resolve?.() ?? resolveTelemetryConsent()) if (!consent.allowed) return const payload = await buildTelemetryPayload({ command: event.command, diff --git a/packages/scaffold/scripts/tests/scripts.spec.ts b/packages/scaffold/scripts/tests/scripts.spec.ts index 6d2ea27991..d1f6cf81d1 100644 --- a/packages/scaffold/scripts/tests/scripts.spec.ts +++ b/packages/scaffold/scripts/tests/scripts.spec.ts @@ -611,12 +611,12 @@ describe('command telemetry', () => { const reporter = { report: () => { sent.push(1) }, flush: async () => {} } await reportCommandTelemetry( { command: 'build', cwd: dir, durationMs: 5, success: true }, - { resolve: async () => ({ allowed: true, reason: 'absent' }), reporter }, + { resolve: async () => ({ allowed: true, reason: 'FULL' }), reporter }, ) expect(sent).toHaveLength(1) await reportCommandTelemetry( { command: 'build', cwd: dir, durationMs: 5, success: true }, - { resolve: async () => ({ allowed: false, reason: 'disabled' }), reporter }, + { resolve: async () => ({ allowed: false, reason: 'DISABLED' }), reporter }, ) expect(sent).toHaveLength(1) await expect(reportCommandTelemetry( diff --git a/packages/scaffold/telemetry/README.i18n.yaml b/packages/scaffold/telemetry/README.i18n.yaml index beb145cfc0..c44209d617 100644 --- a/packages/scaffold/telemetry/README.i18n.yaml +++ b/packages/scaffold/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 packages/scaffold/telemetry/README.md -README.md: 024ff8724604426d3d77609929ca40341f98e0fb -README.zh.md: 8d69d9289ed9c412e4d38fe9b24bb3222f81f94a +README.md: 670937d70dd2cabc0c77306a6239caa23123ae6f +README.zh.md: 6bbd17a46fe9828b3722c5703c1484eafebb8540 diff --git a/packages/scaffold/telemetry/README.md b/packages/scaffold/telemetry/README.md index 024ff87246..670937d70d 100644 --- a/packages/scaffold/telemetry/README.md +++ b/packages/scaffold/telemetry/README.md @@ -2,17 +2,17 @@ English | [中文](README.zh.md) -Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain library the launcher imports around each command; it is **not** a Cordis plugin, because `build` and first-init `create` never boot Cordis. Wiring the reporter into the launcher command dispatch and adding the telemetry consent feature to the `dsh-helper` catalog live in their owning packages, not here. +Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain library the launcher imports around each command; it is not a Cordis plugin because `build` and first-init `create` never boot Cordis. Wiring the reporter into launcher command dispatch lives in its owning package. | Export | Role | |---|---| | `SecretRedactor` | Conservative safety backstop: replaces secret-shaped values (secret-like keys, known token shapes, PEM blocks, URL credentials, high-entropy opaque tokens) with a placeholder in both parsed values (`redactValue`) and raw text (`redactText`). Never drops a field or line. | -| `ConsentResolver` | Parses (never boots) a project `cordis.yml` and reads the telemetry entry's enabled/disabled state as consent; `DO_NOT_TRACK`/CI env force a hard opt-out. | +| `resolveTelemetryConsent` | Reads the shared `DSH_TELEMETRY_MODE`; only `FULL` permits launcher reporting, while `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. | | `buildTelemetryPayload` | Assembles `{command, durationMs, success, cordisYmlContent, packageJsonContent}`, running the redactor over the full `cordis.yml` and `package.json` text. Never reads `.env`; `package.json` ships only alongside a `cordis.yml`, so a command run in a non-SDK directory never uploads that directory's unrelated manifest. | | `getOrCreateAnonymousId` | Random UUID persisted in the harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`$DSH_HOME` > `~/.dsh`), scoped to that home rather than the machine, never derived from git. | | `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. | -Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`. +`DSH_TELEMETRY_MODE` is the single positive consent setting for session and launcher telemetry. `FULL` enables this launcher feed; `FEEDBACK_ONLY` keeps command telemetry off and permits only feedback-triggered Session Log sharing; every other supported state keeps this feed off. The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`). diff --git a/packages/scaffold/telemetry/README.zh.md b/packages/scaffold/telemetry/README.zh.md index 8d69d9289e..6bbd17a46f 100644 --- a/packages/scaffold/telemetry/README.zh.md +++ b/packages/scaffold/telemetry/README.zh.md @@ -2,17 +2,17 @@ [English](README.md) | 中文 -用于 dsh-sdk 工具链的启动器侧 telemetry 原语。这是启动器在执行每个命令时导入的普通库;它**不是** Cordis 插件,因为 `build` 与首次初始化的 `create` 从不启动 Cordis。将 reporter 接入启动器命令分发,并把 telemetry consent 功能加入 `dsh-helper` 目录,属于各自所属包的职责,而不是此包的职责。 +用于 dsh-sdk 工具链的启动器侧 telemetry 原语。这是启动器在执行每个命令时导入的普通库;它不是 Cordis 插件,因为 `build` 与首次初始化的 `create` 从不启动 Cordis。将 reporter 接入启动器命令分发属于其所在包的职责。 | 导出 | 职责 | |---|---| | `SecretRedactor` | 保守的安全后备:在已解析值(`redactValue`)与原始文本(`redactText`)中,将形似密钥的值(疑似密钥的键名、已知 token 格式、PEM 块、URL 凭据、高熵不透明 token)替换为占位符。绝不删除字段或行。 | -| `ConsentResolver` | 解析项目 `cordis.yml`(绝不启动),读取 telemetry 配置项的启用/禁用状态作为 consent;`DO_NOT_TRACK`/CI 环境会强制完全停止上报。 | +| `resolveTelemetryConsent` | 读取共享的 `DSH_TELEMETRY_MODE`;只有 `FULL` 允许启动器上报,`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。 | | `buildTelemetryPayload` | 组装 `{command, durationMs, success, cordisYmlContent, packageJsonContent}`,对完整的 `cordis.yml` 与 `package.json` 文本运行 redactor。绝不读取 `.env`;发送 `package.json` 的前提是同时存在 `cordis.yml`,因此在非 SDK 目录运行的命令不会上传该目录中无关的 manifest(元数据清单)。 | | `getOrCreateAnonymousId` | 将随机 UUID 持久化到 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 harness home(`$DSH_HOME` > `~/.dsh`);其范围限定为该 home,而不是整台机器,且绝不从 git 派生。 | | `TelemetryReporter` | 即发即弃发送:`report()` 绝不阻塞或抛出;无论经过哪条路径,发送操作最终都会结束;`flush()` 可以在上限内排空进行中的发送。 | -Consent 由 `cordis.yml` 中的 telemetry 配置项承载,因此禁用 telemetry 就是禁用该配置项。telemetry 默认上报,只有已经存在的 telemetry 配置项被显式设为 `disabled` 时才关闭:缺少 `cordis.yml`(首次 `create`)、配置项已启用,或 `cordis.yml` 中没有 telemetry 配置项时都会上报。`DO_NOT_TRACK`/CI 始终拒绝。无配置与缺少配置项的默认值可以通过 `ConsentResolver` 配置。 +`DSH_TELEMETRY_MODE` 是会话与启动器 telemetry 的唯一正向授权配置。`FULL` 启用该启动器数据流;`FEEDBACK_ONLY` 保持命令 telemetry 关闭,只允许由反馈触发的 Session Log 共享;其他受支持的状态都会保持该数据流关闭。 收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`)。 diff --git a/packages/scaffold/telemetry/package.json b/packages/scaffold/telemetry/package.json index 6fe62e35fc..e7a5ef2ded 100644 --- a/packages/scaffold/telemetry/package.json +++ b/packages/scaffold/telemetry/package.json @@ -24,9 +24,6 @@ "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", - "dependencies": { - "yaml": "^2.9.0" - }, "peerDependencies": { "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/scaffold/telemetry/src/consent-resolver.ts b/packages/scaffold/telemetry/src/consent-resolver.ts index a4327dc9f7..190481b8ed 100644 --- a/packages/scaffold/telemetry/src/consent-resolver.ts +++ b/packages/scaffold/telemetry/src/consent-resolver.ts @@ -1,42 +1,18 @@ /** - * Consent resolution for dsh-sdk telemetry. + * Consent resolution for dsh-sdk launcher telemetry. * - * Telemetry is OFF only when `cordis.yml` contains a telemetry entry that is - * explicitly `disabled`; every other file state reports (no `cordis.yml`, an - * enabled entry, or no telemetry entry at all). The resolver PARSES `cordis.yml` - * — it never boots a Cordis application — because several launcher commands - * (`build`, `create`) never boot Cordis at all. `DO_NOT_TRACK` and CI - * environment signals force a denial regardless of file state. + * `DSH_TELEMETRY_MODE` is the shared consent setting for session and launcher + * telemetry. Only `FULL` permits launcher reporting; unset and empty values + * resolve to `DISABLED`. * * @module @deepseek-ai/dsh-telemetry/consent-resolver */ -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' -import { parseDocument, type ScalarTag } from 'yaml' - -/** Default `cordis.yml` entry name that carries telemetry consent. */ -export const DEFAULT_TELEMETRY_PLUGIN_NAME = '@deepseek-ai/dsh-telemetry' - -/** - * Passthrough for Cordis' `!!js` expression tag so parsing consent never fails - * on projects that inline JavaScript expressions; the resolver only reads plain - * `name`/`disabled` scalars and does not evaluate expressions. - */ -const JS_EXPRESSION_TAG: ScalarTag = { - tag: 'tag:yaml.org,2002:js', - resolve: value => value, -} - /** Why telemetry is or is not permitted for one command. */ export type ConsentReason = - | 'enabled' - | 'disabled' - | 'absent' - | 'no-config' - | 'do-not-track' - | 'ci' - | 'unreadable' + | 'FULL' + | 'FEEDBACK_ONLY' + | 'DISABLED' /** Resolved telemetry consent for one command invocation. */ export interface ConsentDecision { @@ -46,80 +22,21 @@ export interface ConsentDecision { reason: ConsentReason } -/** Tuning for {@link ConsentResolver}; every field defaults to a documented value. */ -export interface ConsentResolverOptions { - /** `cordis.yml` entry name whose enabled state carries consent. */ - telemetryPluginName?: string - /** Environment used for `DO_NOT_TRACK`/CI checks; defaults to `process.env`. */ - env?: NodeJS.ProcessEnv - /** Honor `DO_NOT_TRACK`/CI env signals as a hard opt-out. Defaults to `true`. */ - honorEnvOptOut?: boolean - /** Consent when `cordis.yml` does not exist yet (first `create`). Defaults to `true` (telemetry is default-on). */ - allowWhenNoConfig?: boolean - /** Consent when `cordis.yml` exists but has no telemetry entry. Defaults to `true` (report unless a present entry is disabled). */ - allowWhenEntryAbsent?: boolean -} - -/** Whether an environment variable is set to a non-empty, non-"0"/"false" value. */ -function envEnabled(value: string | undefined): boolean { - if (value === undefined) return false - const normalized = value.trim().toLowerCase() - return normalized.length > 0 && normalized !== '0' && normalized !== 'false' -} - -/** Read a `cordis.yml` entry's `name`/`disabled` scalars, tolerating `!!js` tags. */ -function readTelemetryEntry(text: string, pluginName: string): { present: boolean; disabled: boolean } { - const document = parseDocument(text, { customTags: [JS_EXPRESSION_TAG] }) - const contents: unknown = document.toJS({ maxAliasCount: -1 }) - if (!Array.isArray(contents)) return { present: false, disabled: false } - for (const entry of contents) { - if (entry === null || typeof entry !== 'object') continue - const record = entry as Record - if (record.name === pluginName) return { present: true, disabled: record.disabled === true } - } - return { present: false, disabled: false } -} - -/** Resolve telemetry consent by parsing a project's `cordis.yml` and the environment. */ -export class ConsentResolver { - readonly #pluginName: string - readonly #env: NodeJS.ProcessEnv - readonly #honorEnvOptOut: boolean - readonly #allowWhenNoConfig: boolean - readonly #allowWhenEntryAbsent: boolean - - /** @param options - plugin name, environment, and default-decision knobs. */ - constructor(options: ConsentResolverOptions = {}) { - this.#pluginName = options.telemetryPluginName ?? DEFAULT_TELEMETRY_PLUGIN_NAME - this.#env = options.env ?? process.env - this.#honorEnvOptOut = options.honorEnvOptOut ?? true - this.#allowWhenNoConfig = options.allowWhenNoConfig ?? true - this.#allowWhenEntryAbsent = options.allowWhenEntryAbsent ?? true - } - - /** - * Resolve consent for a command run in the given project directory. - * @param projectDir - absolute or relative project root containing `cordis.yml`. - * @returns the consent decision and the signal that produced it. - */ - async resolve(projectDir: string): Promise { - if (this.#honorEnvOptOut) { - if (envEnabled(this.#env.DO_NOT_TRACK)) return { allowed: false, reason: 'do-not-track' } - if (envEnabled(this.#env.CI)) return { allowed: false, reason: 'ci' } - } - let text: string - try { - text = await readFile(join(projectDir, 'cordis.yml'), 'utf8') - } catch (error) { - // Missing cordis.yml is the first-init (`create`) path; any other read - // fault is treated conservatively as its own reason. - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return { allowed: this.#allowWhenNoConfig, reason: 'no-config' } - } - return { allowed: false, reason: 'unreadable' } - } - const entry = readTelemetryEntry(text, this.#pluginName) - if (!entry.present) return { allowed: this.#allowWhenEntryAbsent, reason: 'absent' } - return entry.disabled ? { allowed: false, reason: 'disabled' } : { allowed: true, reason: 'enabled' } +/** + * Resolve launcher telemetry consent from the shared telemetry mode. + * @param env - Environment containing `DSH_TELEMETRY_MODE`; defaults to `process.env`. + * @returns Whether launcher telemetry may report and the resolved mode. + */ +export function resolveTelemetryConsent(env: NodeJS.ProcessEnv = process.env): ConsentDecision { + const mode = env.DSH_TELEMETRY_MODE || 'DISABLED' + switch (mode) { + case 'FULL': + return { allowed: true, reason: 'FULL' } + case 'FEEDBACK_ONLY': + return { allowed: false, reason: 'FEEDBACK_ONLY' } + case 'DISABLED': + return { allowed: false, reason: 'DISABLED' } + default: + throw new Error(`unsupported DSH_TELEMETRY_MODE ${JSON.stringify(mode)}`) } } diff --git a/packages/scaffold/telemetry/src/index.ts b/packages/scaffold/telemetry/src/index.ts index 3c0a5beae7..97cc06178f 100644 --- a/packages/scaffold/telemetry/src/index.ts +++ b/packages/scaffold/telemetry/src/index.ts @@ -2,10 +2,9 @@ * Launcher-side telemetry for the dsh-sdk toolchain: secret redaction, consent * resolution, anonymous id, payload assembly, and a fire-and-forget reporter. * - * This package is a plain library the launcher imports around each command — it - * is NOT a Cordis plugin (several commands never boot Cordis). Wiring it into - * the launcher command dispatch and the helper feature catalog lives outside - * this package. + * This package is a plain library the launcher imports around each command; it + * is not a Cordis plugin because several commands never boot Cordis. Wiring + * reporting into launcher command dispatch lives outside this package. * * FIXME: rename to `@deepseek-ai/dsh-sdk-telemetry` before the first tagged release — * the current name collides with the `dsh-session-telemetry` family; this is @@ -24,13 +23,11 @@ export { } from './secret-redactor.ts' export type { SecretRedactorOptions } from './secret-redactor.ts' export { - ConsentResolver, - DEFAULT_TELEMETRY_PLUGIN_NAME, + resolveTelemetryConsent, } from './consent-resolver.ts' export type { ConsentDecision, ConsentReason, - ConsentResolverOptions, } from './consent-resolver.ts' export { ANONYMOUS_ID_FILE_NAME, diff --git a/packages/scaffold/telemetry/tests/consent-resolver.spec.ts b/packages/scaffold/telemetry/tests/consent-resolver.spec.ts index b9c9300e78..9a0ab4f806 100644 --- a/packages/scaffold/telemetry/tests/consent-resolver.spec.ts +++ b/packages/scaffold/telemetry/tests/consent-resolver.spec.ts @@ -1,132 +1,41 @@ -import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { ConsentResolver, DEFAULT_TELEMETRY_PLUGIN_NAME, type ConsentDecision } from '@deepseek-ai/dsh-telemetry' +import { describe, expect, it } from 'vitest' +import { + resolveTelemetryConsent, + type ConsentDecision, +} from '@deepseek-ai/dsh-telemetry' -const dirs: string[] = [] - -async function projectDir(cordisYml?: string): Promise { - const dir = await mkdtemp(join(tmpdir(), 'dsh-consent-')) - dirs.push(dir) - if (cordisYml !== undefined) await writeFile(join(dir, 'cordis.yml'), cordisYml, 'utf8') - return dir -} - -afterEach(async () => { - await Promise.all(dirs.splice(0).map(dir => import('node:fs/promises').then(fs => fs.rm(dir, { recursive: true, force: true })))) -}) - -const enabledYml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n` - -describe('ConsentResolver environment opt-out', () => { - it('denies when DO_NOT_TRACK is set', async () => { - const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' } }).resolve(await projectDir(enabledYml)) - expect(decision).toEqual({ allowed: false, reason: 'do-not-track' }) +describe('resolveTelemetryConsent', () => { + it('denies launcher telemetry when the shared mode is unset or empty', () => { + expect(resolveTelemetryConsent({})).toEqual({ allowed: false, reason: 'DISABLED' }) + expect(resolveTelemetryConsent({ DSH_TELEMETRY_MODE: '' })) + .toEqual({ allowed: false, reason: 'DISABLED' }) }) - it('denies when CI is set', async () => { - const decision = await new ConsentResolver({ env: { CI: 'true' } }).resolve(await projectDir(enabledYml)) - expect(decision).toEqual({ allowed: false, reason: 'ci' }) + it('denies launcher telemetry in DISABLED and FEEDBACK_ONLY modes', () => { + expect(resolveTelemetryConsent({ DSH_TELEMETRY_MODE: 'DISABLED' })) + .toEqual({ allowed: false, reason: 'DISABLED' }) + expect(resolveTelemetryConsent({ DSH_TELEMETRY_MODE: 'FEEDBACK_ONLY' })) + .toEqual({ allowed: false, reason: 'FEEDBACK_ONLY' }) }) - it('ignores falsy env values and continues to the file', async () => { - const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '0', CI: 'false' } }) - .resolve(await projectDir(enabledYml)) - expect(decision).toEqual({ allowed: true, reason: 'enabled' }) + it('allows launcher telemetry only in FULL mode', () => { + expect(resolveTelemetryConsent({ DSH_TELEMETRY_MODE: 'FULL' })) + .toEqual({ allowed: true, reason: 'FULL' }) }) - it('can be told to ignore env opt-out signals', async () => { - const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' }, honorEnvOptOut: false }) - .resolve(await projectDir(enabledYml)) - expect(decision).toEqual({ allowed: true, reason: 'enabled' }) + it('rejects an unsupported non-empty mode', () => { + expect(() => resolveTelemetryConsent({ DSH_TELEMETRY_MODE: 'full' })) + .toThrow('unsupported DSH_TELEMETRY_MODE "full"') }) - it('reads process.env by default', async () => { - const saved = { CI: process.env.CI, DO_NOT_TRACK: process.env.DO_NOT_TRACK } - delete process.env.CI - delete process.env.DO_NOT_TRACK + it('reads process.env by default', () => { + const saved = process.env.DSH_TELEMETRY_MODE + process.env.DSH_TELEMETRY_MODE = 'FULL' try { - const decision = await new ConsentResolver().resolve(await projectDir(enabledYml)) - expect(decision).toEqual({ allowed: true, reason: 'enabled' }) + expect(resolveTelemetryConsent()).toEqual({ allowed: true, reason: 'FULL' }) } finally { - if (saved.CI !== undefined) process.env.CI = saved.CI - if (saved.DO_NOT_TRACK !== undefined) process.env.DO_NOT_TRACK = saved.DO_NOT_TRACK + if (saved === undefined) delete process.env.DSH_TELEMETRY_MODE + else process.env.DSH_TELEMETRY_MODE = saved } }) }) - -describe('ConsentResolver cordis.yml state', () => { - const resolver = new ConsentResolver({ env: {} }) - - it('allows when the telemetry entry is enabled', async () => { - expect(await resolver.resolve(await projectDir(enabledYml))) - .toEqual({ allowed: true, reason: 'enabled' }) - }) - - it('denies when the telemetry entry is disabled', async () => { - const yml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n disabled: true\n` - expect(await resolver.resolve(await projectDir(yml))) - .toEqual({ allowed: false, reason: 'disabled' }) - }) - - it('tolerates !!js expression tags while reading plain scalars', async () => { - const yml = [ - '- id: telemetry', - ` name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'`, - '- id: llm', - ' name: \'@deepseek-ai/dsh-llm-deepseek\'', - ' config:', - ' apiKeyEnv: DEEPSEEK_API_KEY', - ' model: !!js process.env.DEEPSEEK_MODEL', - '', - ].join('\n') - expect(await resolver.resolve(await projectDir(yml))) - .toEqual({ allowed: true, reason: 'enabled' }) - }) - - it('reports (allows) when cordis.yml has no telemetry entry', async () => { - const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' - expect(await resolver.resolve(await projectDir(yml))) - .toEqual({ allowed: true, reason: 'absent' }) - }) - - it('can be told to deny when the entry is absent', async () => { - const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' - const decision = await new ConsentResolver({ env: {}, allowWhenEntryAbsent: false }).resolve(await projectDir(yml)) - expect(decision).toEqual({ allowed: false, reason: 'absent' }) - }) - - it('skips non-object sequence items and a non-sequence root, still reporting absent', async () => { - expect(await resolver.resolve(await projectDir('- just-a-string\n- id: x\n name: y\n'))) - .toEqual({ allowed: true, reason: 'absent' }) - expect(await resolver.resolve(await projectDir('root: not-a-sequence\n'))) - .toEqual({ allowed: true, reason: 'absent' }) - }) - - it('honors a custom telemetry plugin name', async () => { - const yml = '- id: t\n name: \'my-consent-marker\'\n' - const decision = await new ConsentResolver({ env: {}, telemetryPluginName: 'my-consent-marker' }) - .resolve(await projectDir(yml)) - expect(decision).toEqual({ allowed: true, reason: 'enabled' }) - }) -}) - -describe('ConsentResolver missing or unreadable cordis.yml', () => { - it('reports no-config and allows by default on first init', async () => { - expect(await new ConsentResolver({ env: {} }).resolve(await projectDir())) - .toEqual({ allowed: true, reason: 'no-config' }) - }) - - it('can deny on first init', async () => { - const decision = await new ConsentResolver({ env: {}, allowWhenNoConfig: false }).resolve(await projectDir()) - expect(decision).toEqual({ allowed: false, reason: 'no-config' }) - }) - - it('denies with an unreadable reason when cordis.yml is not a regular file', async () => { - const dir = await projectDir() - await mkdir(join(dir, 'cordis.yml')) // a directory where the resolver expects a file - expect(await new ConsentResolver({ env: {} }).resolve(dir)) - .toEqual({ allowed: false, reason: 'unreadable' }) - }) -}) diff --git a/packages/scaffold/telemetry/tests/reporter.spec.ts b/packages/scaffold/telemetry/tests/reporter.spec.ts index 5d8a490b9e..5549251512 100644 --- a/packages/scaffold/telemetry/tests/reporter.spec.ts +++ b/packages/scaffold/telemetry/tests/reporter.spec.ts @@ -9,8 +9,8 @@ import { type TelemetryPayload, } from '@deepseek-ai/dsh-telemetry' -const ALLOW: ConsentDecision = { allowed: true, reason: 'enabled' } -const DENY: ConsentDecision = { allowed: false, reason: 'disabled' } +const ALLOW: ConsentDecision = { allowed: true, reason: 'FULL' } +const DENY: ConsentDecision = { allowed: false, reason: 'DISABLED' } const anon = (value = 'anon-123'): (() => Promise) => async () => value as AnonymousId function okResponse(): Response { diff --git a/packages/session/session-telemetry-otel/README.i18n.yaml b/packages/session/session-telemetry-otel/README.i18n.yaml index 2897eb7dac..85d6efa55a 100644 --- a/packages/session/session-telemetry-otel/README.i18n.yaml +++ b/packages/session/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 packages/session/session-telemetry-otel/README.md -README.md: 585995ce409255df9608bc33b76625374bc67669 -README.zh.md: 7f0b93363fbb4aebb80f0d3cc8108e58ce3f647f +README.md: 4220ed0fc27db4535ae4739d6fe784ca17518535 +README.zh.md: e0f33a7df2fcd29c79fc894b9781c8b43e2fc547 diff --git a/packages/session/session-telemetry-otel/README.md b/packages/session/session-telemetry-otel/README.md index 585995ce40..4220ed0fc2 100644 --- a/packages/session/session-telemetry-otel/README.md +++ b/packages/session/session-telemetry-otel/README.md @@ -10,7 +10,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th - id: telemetry-otel name: '@deepseek-ai/dsh-session-telemetry-otel' config: - mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED + mode: FULL # explicit opt-in; default: DISABLED shutdownTimeoutMillis: 3000 # optional; defaults to 3000 exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter url: https://collector.example.com/v1/logs @@ -21,9 +21,9 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th | `mode` | Behavior | |---|---| -| `FULL` | Default. Each projected record, including lifecycle ops records, is handed to the OTel SDK immediately. | +| `FULL` | Each projected record, including lifecycle ops records, is handed to the OTel SDK immediately. | | `FEEDBACK_ONLY` | Each `feedback/record` replays, projects, and redacts the canonical session-log suffix through that event. Later records wait for another feedback event and remain local if none arrives. | -| `DISABLED` | No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. | +| `DISABLED` | Default. No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. | Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`TelemetryMode.FULL`, `TelemetryMode.FEEDBACK_ONLY`, or `TelemetryMode.DISABLED`); raw string literals are not assignable. Serialized Cordis configuration continues to use the string values shown above. diff --git a/packages/session/session-telemetry-otel/README.zh.md b/packages/session/session-telemetry-otel/README.zh.md index 7f0b93363f..e0f33a7df2 100644 --- a/packages/session/session-telemetry-otel/README.zh.md +++ b/packages/session/session-telemetry-otel/README.zh.md @@ -10,7 +10,7 @@ - id: telemetry-otel name: '@deepseek-ai/dsh-session-telemetry-otel' config: - mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED + mode: FULL # explicit opt-in; default: DISABLED shutdownTimeoutMillis: 3000 # optional; defaults to 3000 exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter url: https://collector.example.com/v1/logs @@ -21,9 +21,9 @@ | `mode` | 行为 | |---|---| -| `FULL` | 默认值。每条已投影记录都立即交给 OTel SDK,包括生命周期运维记录。 | +| `FULL` | 每条已投影记录都立即交给 OTel SDK,包括生命周期运维记录。 | | `FEEDBACK_ONLY` | 每个 `feedback/record` 都会回放权威会话日志中截至该事件的后缀,并进行投影与脱敏。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 | -| `DISABLED` | 不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 | +| `DISABLED` | 默认值。不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 | 程序化 TypeScript 配置使用导出的 `TelemetryMode` 枚举(`TelemetryMode.FULL`、`TelemetryMode.FEEDBACK_ONLY` 或 `TelemetryMode.DISABLED`);原始字符串字面量不可赋值。序列化后的 Cordis 配置继续使用上表所示的字符串值。 diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index 50776f7d3f..d16e8a4d2d 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -47,7 +47,7 @@ export enum TelemetryMode { } /** Default session-sharing policy for schema and direct construction. */ -export const DEFAULT_TELEMETRY_MODE = TelemetryMode.FULL +export const DEFAULT_TELEMETRY_MODE = TelemetryMode.DISABLED const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local' const NON_CANONICAL_FEEDBACK_WARNING = 'session telemetry ignored a feedback event absent from the canonical session log' @@ -77,7 +77,7 @@ function assertNever(value: never): never { * and shutdown deadline at plugin load; `DISABLED` reads neither. */ export interface Config { - /** Sharing policy; defaults to immediate `FULL` delivery. */ + /** Sharing policy; defaults to local-only `DISABLED` behavior. */ mode?: TelemetryMode /** * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index 511c95c0d8..a3d7b7c25d 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -99,6 +99,7 @@ async function boot(url: string) { const ctx = new Context() await ctx.plugin(SessionStore) const fiber = await ctx.plugin(TelemetryOtel, { + mode: TelemetryMode.FULL, exporter: { url, headers: { authorization: 'Bearer test-token' } }, }) return { ctx, fiber } @@ -180,6 +181,7 @@ describe('TelemetryOtel wire', () => { const ctx = new Context() await ctx.plugin(SessionStore) const fiber = await ctx.plugin(TelemetryOtel, { + mode: TelemetryMode.FULL, exporter: { url }, processor: { scheduledDelayMillis: 10 }, }) @@ -210,6 +212,7 @@ describe('TelemetryOtel wire', () => { const ctx = new Context() await ctx.plugin(SessionStore) const fiber = await ctx.plugin(TelemetryOtel, { + mode: TelemetryMode.FULL, exporter: { url, timeoutMillis: 60_000 }, processor: { scheduledDelayMillis: 10, exportTimeoutMillis: 60_000 }, shutdownTimeoutMillis: 50, @@ -238,6 +241,7 @@ describe('TelemetryOtel wire', () => { // 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, { + mode: TelemetryMode.FULL, exporter: { url, compression: 'gzip' }, } as Config) const session = ctx.sessions.create(SessionId('gzip'), { meta: {} }) @@ -364,16 +368,24 @@ describe('TelemetryOtel wire', () => { expect(captures).toEqual([]) }) - it('defaults direct construction to full delivery', async () => { + it('defaults direct construction to disabled delivery', async () => { const { url, captures } = await mockCollector() const ctx = new Context() await ctx.plugin(SessionStore) - new TelemetryOtel(ctx, { exporter: { url } }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + new TelemetryOtel(ctx, { + exporter: { url }, + processor: { maxExportBatchSize: 0 }, + }) const session = ctx.sessions.create(SessionId('direct-default'), { meta: {} }) session.append('turn/start', { turn: 1 }) + recordFeedback(session, 'local report') await ctx.fiber.dispose() - expect(eventTypes(captures)).toContain('turn/start') + expect(warn).toHaveBeenCalledWith( + 'session telemetry is DISABLED; nothing will be shared and this feedback remains local', + ) + expect(captures).toEqual([]) }) }) @@ -382,23 +394,23 @@ describe('TelemetryOtel config fails loud', () => { expectTypeOf().toEqualTypeOf() expectTypeOf<'FULL'>().not.toExtend() expectTypeOf().toExtend() - expect(DEFAULT_TELEMETRY_MODE).toBe(TelemetryMode.FULL) + expect(DEFAULT_TELEMETRY_MODE).toBe(TelemetryMode.DISABLED) expect(Config({}).mode).toBe(DEFAULT_TELEMETRY_MODE) }) 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\)/], + [{ mode: TelemetryMode.FULL }, /exporter\.url is required/], + [{ mode: TelemetryMode.FULL, exporter: { url: '' } }, /exporter\.url is required/], + [{ mode: TelemetryMode.FULL, exporter: { url: 'not a url' } }, /not a valid URL/], + [{ mode: TelemetryMode.FULL, exporter: { url: 'ftp://collector' } }, /must be http\(s\)/], [{ mode: TelemetryMode.FEEDBACK_ONLY }, /exporter\.url is required/], [{ mode: 'INVALID' }, /INVALID/], // 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/], - [{ exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: 0 }, /shutdownTimeoutMillis/], - [{ exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/], + [{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/], + [{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/], + [{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: 0 }, /shutdownTimeoutMillis/], + [{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/], ])('rejects %j at plugin load', async (config, message) => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -463,7 +475,7 @@ describe('dsh-session-telemetry-otel real-load-path guard', () => { const unwrapped = loader.unwrapExports(module) as Parameters[0] const ctx = new Context() await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(unwrapped, { exporter: { url } }) + const fiber = await ctx.plugin(unwrapped, { mode: TelemetryMode.FULL, exporter: { url } }) expect(ctx.telemetry).toBeInstanceOf(TelemetryOtel) await fiber.dispose() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 732e00e4ee..c41ecab4a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5493,10 +5493,6 @@ importers: version: link:../../../vendor/cordis packages/scaffold/telemetry: - dependencies: - yaml: - specifier: ^2.9.0 - version: 2.9.0 devDependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 63050b2079..63a1243f2b 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nSession Logs stay local by default. Set `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` to share a Session Log only when submitting feedback, or `DSH_TELEMETRY_MODE=FULL` to upload continuously. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\nSession Log 默认留在本地。设置 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 可仅在提交反馈时共享 Session Log,设置 `DSH_TELEMETRY_MODE=FULL` 可持续上传。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", From cc455c4f5a1ef19f5c56c117410758540365c752 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 11:12:46 +0800 Subject: [PATCH 02/61] docs(agent-notes): frame telemetry default-off as public beta transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem now explains that DSH is entering public beta and default-off is the appropriate stance for that phase — consistent with internal testing having default-on for diagnostics. Update Consequences to match. --- .../feature/2026-08-10-telemetry-default-off.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-10-telemetry-default-off.md | 4 ++-- .../feature/2026-08-10-telemetry-default-off.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 4817fe52bd..409aaa4f2b 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.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-08-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: e4a075fb6eee606057ffe1b953af525169d9cb80 -2026-08-10-telemetry-default-off.zh.md: 4f3b27e76fcd895269b0934f3623ce355e377913 +2026-08-10-telemetry-default-off.md: fce146cf1c075630ca498de3e511d96fe1a328a8 +2026-08-10-telemetry-default-off.zh.md: 84746d7e8661467e7ea49a940bef8b894cd3f3fc diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index e4a075fb6e..fce146cf1c 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -6,7 +6,7 @@ English | [中文](2026-08-10-telemetry-default-off.zh.md) ## Problem -DeepSeek Harness has two outbound telemetry feeds. The session OTel backend could export complete session content, tool data, prompts, and workspace paths when its mode was omitted, while the dsh-sdk launcher feed reported by default. A fresh installation therefore permitted outbound reporting without a positive deployment choice. +DeepSeek Harness is entering its public beta phase. During internal testing, telemetry was enabled by default — the session OTel backend and the dsh-sdk launcher both reported without a positive deployment choice — to help diagnose reported issues. For public beta, outbound reporting must require an explicit opt-in from the deployment. ## Decision @@ -28,4 +28,4 @@ The versioned Web welcome notice states that Session Log upload is off by defaul ## Consequences -Fresh profiles and projects make no telemetry network request. Internal deployments select one mode for both feeds: `FEEDBACK_ONLY` permits only feedback-triggered Session Log sharing, while `FULL` also enables launcher reporting. The existing hard opt-out remains effective, and uploading modes retain their endpoint validation, redaction responsibility, batching, and shutdown behavior. +All public beta profiles and projects make no telemetry network request by default — a break from the internal-testing default of reporting when configured. Internal deployments that still need reporting select one mode for both feeds: `FEEDBACK_ONLY` permits only feedback-triggered Session Log sharing, while `FULL` also enables launcher reporting. The existing hard opt-out remains effective, and uploading modes retain their endpoint validation, redaction responsibility, batching, and shutdown behavior. diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index 4f3b27e76f..84746d7e86 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -DeepSeek Harness 有两路出站遥测数据流。会话 OTel 后端在省略 `mode` 时可能导出完整会话内容、工具数据、提示词和工作区路径,而 dsh-sdk 启动器数据流默认上报。因此,全新安装无需部署方明确选择便允许向外上报。 +DeepSeek Harness 正在进入公测阶段。在内测阶段,遥测默认开启——会话 OTel 后端和 dsh-sdk 启动器均无需部署方明确选择即可上报——以帮助诊断上报的问题。进入公测后,出站上报必须由部署方显式启用。 ## 决策 @@ -28,4 +28,4 @@ dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cord ## 后果 -全新 profile 和项目不发出任何遥测网络请求。内部部署为两路数据流选择一个模式:`FEEDBACK_ONLY` 只允许由反馈触发的 Session Log 共享,`FULL` 还会启用启动器上报。现有硬性退出继续生效,上传模式也保留 endpoint 校验、脱敏责任、批处理和关闭行为。 +所有公测 profile 和项目默认不发出任何遥测网络请求——与内测阶段配置后即上报的默认行为不同。仍需上报的内部部署为两路数据流选择一个模式:`FEEDBACK_ONLY` 只允许由反馈触发的 Session Log 共享,`FULL` 还会启用启动器上报。现有硬性退出继续生效,上传模式也保留 endpoint 校验、脱敏责任、批处理和关闭行为。 From 183aa65deb29f7df8fb320cc3ba448c9d1cdf661 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 11:44:23 +0800 Subject: [PATCH 03/61] docs(agent-notes): revert public beta framing in default-off note The repo's user-visible copy (root README, welcome notice) still describes internal testing, so 'entering public beta' was an unrecorded fact and contradicted this PR's own copy. Problem now states the recorded facts: internal testing mounted telemetry with a baked-in production endpoint and both feeds reported by default to help diagnose problems, so a fresh installation exported without a positive deployment choice. Consequences returns to the phase-independent statement. --- .../feature/2026-08-10-telemetry-default-off.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-10-telemetry-default-off.md | 4 ++-- .../feature/2026-08-10-telemetry-default-off.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 409aaa4f2b..2e788c2b43 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.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-08-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: fce146cf1c075630ca498de3e511d96fe1a328a8 -2026-08-10-telemetry-default-off.zh.md: 84746d7e8661467e7ea49a940bef8b894cd3f3fc +2026-08-10-telemetry-default-off.md: ce5fba46842c235e386748e07884589656900c3f +2026-08-10-telemetry-default-off.zh.md: a2ca5466139e5b2c95082ee1ba46ed0a752b0a0b diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index fce146cf1c..ce5fba4684 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -6,7 +6,7 @@ English | [中文](2026-08-10-telemetry-default-off.zh.md) ## Problem -DeepSeek Harness is entering its public beta phase. During internal testing, telemetry was enabled by default — the session OTel backend and the dsh-sdk launcher both reported without a positive deployment choice — to help diagnose reported issues. For public beta, outbound reporting must require an explicit opt-in from the deployment. +DeepSeek Harness has two outbound telemetry feeds. During internal testing, the shared base mounted telemetry with a baked-in production endpoint, and both feeds reported by default to help diagnose reported problems: the session OTel backend could export complete session content, tool data, prompts, and workspace paths when its mode was omitted, while the dsh-sdk launcher feed reported by default. A fresh installation therefore permitted outbound reporting without a positive deployment choice. ## Decision @@ -28,4 +28,4 @@ The versioned Web welcome notice states that Session Log upload is off by defaul ## Consequences -All public beta profiles and projects make no telemetry network request by default — a break from the internal-testing default of reporting when configured. Internal deployments that still need reporting select one mode for both feeds: `FEEDBACK_ONLY` permits only feedback-triggered Session Log sharing, while `FULL` also enables launcher reporting. The existing hard opt-out remains effective, and uploading modes retain their endpoint validation, redaction responsibility, batching, and shutdown behavior. +Fresh profiles and projects make no telemetry network request. Internal deployments select one mode for both feeds: `FEEDBACK_ONLY` permits only feedback-triggered Session Log sharing, while `FULL` also enables launcher reporting. The existing hard opt-out remains effective, and uploading modes retain their endpoint validation, redaction responsibility, batching, and shutdown behavior. diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index 84746d7e86..a2ca546613 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -DeepSeek Harness 正在进入公测阶段。在内测阶段,遥测默认开启——会话 OTel 后端和 dsh-sdk 启动器均无需部署方明确选择即可上报——以帮助诊断上报的问题。进入公测后,出站上报必须由部署方显式启用。 +DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础配置挂载了带内建生产 endpoint 的遥测,两路数据流默认上报以帮助诊断上报的问题:会话 OTel 后端在省略 `mode` 时可能导出完整会话内容、工具数据、提示词和工作区路径,而 dsh-sdk 启动器数据流默认上报。因此,全新安装无需部署方明确选择便允许向外上报。 ## 决策 @@ -28,4 +28,4 @@ dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cord ## 后果 -所有公测 profile 和项目默认不发出任何遥测网络请求——与内测阶段配置后即上报的默认行为不同。仍需上报的内部部署为两路数据流选择一个模式:`FEEDBACK_ONLY` 只允许由反馈触发的 Session Log 共享,`FULL` 还会启用启动器上报。现有硬性退出继续生效,上传模式也保留 endpoint 校验、脱敏责任、批处理和关闭行为。 +全新 profile 和项目不发出任何遥测网络请求。内部部署为两路数据流选择一个模式:`FEEDBACK_ONLY` 只允许由反馈触发的 Session Log 共享,`FULL` 还会启用启动器上报。现有硬性退出继续生效,上传模式也保留 endpoint 校验、脱敏责任、批处理和关闭行为。 From 57408d07fcbc159dc436233984d1ae9351a01c92 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 11:59:05 +0800 Subject: [PATCH 04/61] docs(telemetry): disclose launcher telemetry in FULL notice The FULL opt-in also enables dsh-sdk command telemetry (anonymous ID, command result, redacted project configuration), which the welcome notice and root README did not disclose. Update onboarding copy, bump WELCOME_NOTICE_VERSION, sync the welcome and translation-prompt snapshots, and re-record bilingual pairing hashes. Also de-duplicate 'reported by default' in the default-off Agent Note. --- .../feature/2026-08-10-telemetry-default-off.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-10-telemetry-default-off.md | 2 +- .../feature/2026-08-10-telemetry-default-off.zh.md | 2 +- README.i18n.yaml | 4 ++-- README.md | 2 +- README.zh.md | 2 +- .../onboarding-deepseek-config/welcome.expected.md | 2 +- packages/client/ui-settings-general/src/onboarding-copy.ts | 6 +++--- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 2e788c2b43..54e440cf3d 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.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-08-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: ce5fba46842c235e386748e07884589656900c3f -2026-08-10-telemetry-default-off.zh.md: a2ca5466139e5b2c95082ee1ba46ed0a752b0a0b +2026-08-10-telemetry-default-off.md: 59fb6ff4dcc00a54abf39eb7bbb6232035743774 +2026-08-10-telemetry-default-off.zh.md: 775a648f0f0026c5c2128f1f24a646161fa641d9 diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index ce5fba4684..59fb6ff4dc 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -6,7 +6,7 @@ English | [中文](2026-08-10-telemetry-default-off.zh.md) ## Problem -DeepSeek Harness has two outbound telemetry feeds. During internal testing, the shared base mounted telemetry with a baked-in production endpoint, and both feeds reported by default to help diagnose reported problems: the session OTel backend could export complete session content, tool data, prompts, and workspace paths when its mode was omitted, while the dsh-sdk launcher feed reported by default. A fresh installation therefore permitted outbound reporting without a positive deployment choice. +DeepSeek Harness has two outbound telemetry feeds. During internal testing, the shared base mounted telemetry with a baked-in production endpoint, and both feeds reported by default to help diagnose reported problems: the session OTel backend could export complete session content, tool data, prompts, and workspace paths when its mode was omitted, while the dsh-sdk launcher feed did so unconditionally. A fresh installation therefore permitted outbound reporting without a positive deployment choice. ## Decision diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index a2ca546613..775a648f0f 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础配置挂载了带内建生产 endpoint 的遥测,两路数据流默认上报以帮助诊断上报的问题:会话 OTel 后端在省略 `mode` 时可能导出完整会话内容、工具数据、提示词和工作区路径,而 dsh-sdk 启动器数据流默认上报。因此,全新安装无需部署方明确选择便允许向外上报。 +DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础配置挂载了带内建生产 endpoint 的遥测,两路数据流默认上报以帮助诊断上报的问题:会话 OTel 后端在省略 `mode` 时可能导出完整会话内容、工具数据、提示词和工作区路径,而 dsh-sdk 启动器数据流则无条件下报。因此,全新安装无需部署方明确选择便允许向外上报。 ## 决策 diff --git a/README.i18n.yaml b/README.i18n.yaml index 9f0c979e45..a6a4b85c44 100644 --- a/README.i18n.yaml +++ b/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 -README.md: 65b51f60bfaafff71d7eb4a8f34a57f3dd3ed12f -README.zh.md: 73c17b84ff3ed90e0d1a0ed94454c405e2e0c8f2 +README.md: e662caa94542d86bcc9dcab652b5932a89751b9a +README.zh.md: e4dd46ea88410b71498b7d3bbd461e4f5e4e61c6 diff --git a/README.md b/README.md index 65b51f60bf..e662caa945 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ It uses an architecture where **everything is a plugin**. DeepSeek Harness is under internal testing. Features and interfaces may change. -Session Logs stay local by default. Set `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` to share a Session Log only when submitting feedback, or `DSH_TELEMETRY_MODE=FULL` to upload continuously. Send feedback through the internal WeChat group. +Session Logs stay local by default. Set `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` to share a Session Log only when submitting feedback, or `DSH_TELEMETRY_MODE=FULL` to upload continuously; `FULL` also enables dsh-sdk command telemetry reporting an anonymous ID, the command result, and redacted project configuration. Send feedback through the internal WeChat group. ## Install diff --git a/README.zh.md b/README.zh.md index 73c17b84ff..e4dd46ea88 100644 --- a/README.zh.md +++ b/README.zh.md @@ -10,7 +10,7 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 DeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。 -Session Log 默认留在本地。设置 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 可仅在提交反馈时共享 Session Log,设置 `DSH_TELEMETRY_MODE=FULL` 可持续上传。请通过内部企业微信群反馈问题和建议。 +Session Log 默认留在本地。设置 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 可仅在提交反馈时共享 Session Log,设置 `DSH_TELEMETRY_MODE=FULL` 可持续上传;`FULL` 同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果和脱敏后的项目配置。请通过内部企业微信群反馈问题和建议。 ## 安装 diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md index 27e1935b23..8332305eed 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/welcome.expected.md @@ -3,7 +3,7 @@ - paragraph: 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。 - blockquote: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。 - paragraph: - - text: 内测版本默认不会上传 Session Log。如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY;如需持续上传,可以设置 DSH_TELEMETRY_MODE=FULL。另外, + - text: 内测版本默认不会上传 Session Log。如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY;如需持续上传,可以设置 DSH_TELEMETRY_MODE=FULL,但该模式同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果以及脱敏后的项目配置。另外, - strong: 如果您有任何反馈与建议,请在企业微信群中留言告诉我们 - text: 。每一条反馈,都会帮助我们把它打磨得更好。 - button "继续" diff --git a/packages/client/ui-settings-general/src/onboarding-copy.ts b/packages/client/ui-settings-general/src/onboarding-copy.ts index e38ff3203d..8b29402b4b 100644 --- a/packages/client/ui-settings-general/src/onboarding-copy.ts +++ b/packages/client/ui-settings-general/src/onboarding-copy.ts @@ -8,7 +8,7 @@ export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion' * Bump only when the notice changes materially and every user should see it * again. The acknowledgement is compared for exact equality. */ -export const WELCOME_NOTICE_VERSION = '2026-08-10.1' +export const WELCOME_NOTICE_VERSION = '2026-08-11.1' /** The complete editable welcome notice in both supported GUI locales. */ export const WELCOME_NOTICE_COPY = { @@ -17,7 +17,7 @@ export const WELCOME_NOTICE_COPY = { paragraphs: [ '感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。', '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。', - '内测版本默认不会上传 Session Log。如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY;如需持续上传,可以设置 DSH_TELEMETRY_MODE=FULL。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', + '内测版本默认不会上传 Session Log。如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY;如需持续上传,可以设置 DSH_TELEMETRY_MODE=FULL,但该模式同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果以及脱敏后的项目配置。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', ], feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们', continueLabel: '继续', @@ -27,7 +27,7 @@ export const WELCOME_NOTICE_COPY = { paragraphs: [ '感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。', '“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。', - '内测版本默认不会上传 Session Log。如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY;如需持续上传,可以设置 DSH_TELEMETRY_MODE=FULL。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', + '内测版本默认不会上传 Session Log。如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY;如需持续上传,可以设置 DSH_TELEMETRY_MODE=FULL,但该模式同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果以及脱敏后的项目配置。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。', ], feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们', continueLabel: '继续', diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 63a1243f2b..bd18aa0b1e 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nSession Logs stay local by default. Set `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` to share a Session Log only when submitting feedback, or `DSH_TELEMETRY_MODE=FULL` to upload continuously. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nSession Logs stay local by default. Set `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` to share a Session Log only when submitting feedback, or `DSH_TELEMETRY_MODE=FULL` to upload continuously; `FULL` also enables dsh-sdk command telemetry reporting an anonymous ID, the command result, and redacted project configuration. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\nSession Log 默认留在本地。设置 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 可仅在提交反馈时共享 Session Log,设置 `DSH_TELEMETRY_MODE=FULL` 可持续上传。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\nSession Log 默认留在本地。设置 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 可仅在提交反馈时共享 Session Log,设置 `DSH_TELEMETRY_MODE=FULL` 可持续上传;`FULL` 同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果和脱敏后的项目配置。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", From 560cabf848d93e05b6903177b3db66d7eb65eea1 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 12:14:58 +0800 Subject: [PATCH 05/61] docs(telemetry): fix zh wording and complete FULL disclosure description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the non-standard '无条件下报' with '无条件外发' in the default-off note, and complete the welcome-notice descriptions in the note and the ui-settings-general READMEs to mention the FULL launcher telemetry disclosure. Re-record bilingual pairing hashes. --- .../feature/2026-08-10-telemetry-default-off.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-10-telemetry-default-off.md | 2 +- .../feature/2026-08-10-telemetry-default-off.zh.md | 4 ++-- packages/client/ui-settings-general/README.i18n.yaml | 4 ++-- packages/client/ui-settings-general/README.md | 2 +- packages/client/ui-settings-general/README.zh.md | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 54e440cf3d..7274b41848 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.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-08-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: 59fb6ff4dcc00a54abf39eb7bbb6232035743774 -2026-08-10-telemetry-default-off.zh.md: 775a648f0f0026c5c2128f1f24a646161fa641d9 +2026-08-10-telemetry-default-off.md: a56b08820b4bf4f9ffa91944b1d9c961d1f3826f +2026-08-10-telemetry-default-off.zh.md: 90b0ae5a7627dcdddf68c11e59e52b80b9c807f1 diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index 59fb6ff4dc..a56b08820b 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -14,7 +14,7 @@ Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. This rule supersedes only the default-on launcher consent in the [SDK follow-up proposal](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md); its other capabilities remain proposed. -The versioned Web welcome notice states that Session Log upload is off by default and names `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` as the two opt-in choices. Its version changes with that material privacy statement so every profile acknowledges the current copy. +The versioned Web welcome notice states that Session Log upload is off by default, names `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` as the two opt-in choices, and discloses that `FULL` also enables dsh-sdk command telemetry. Its version changes with that material privacy statement so every profile acknowledges the current copy. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index 775a648f0f..90b0ae5a76 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础配置挂载了带内建生产 endpoint 的遥测,两路数据流默认上报以帮助诊断上报的问题:会话 OTel 后端在省略 `mode` 时可能导出完整会话内容、工具数据、提示词和工作区路径,而 dsh-sdk 启动器数据流则无条件下报。因此,全新安装无需部署方明确选择便允许向外上报。 +DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础配置挂载了带内建生产 endpoint 的遥测,两路数据流默认上报以帮助诊断上报的问题:会话 OTel 后端在省略 `mode` 时可能导出完整会话内容、工具数据、提示词和工作区路径,而 dsh-sdk 启动器数据流则无条件外发。因此,全新安装无需部署方明确选择便允许向外上报。 ## 决策 @@ -14,7 +14,7 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础 dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。此规则仅取代 [SDK 后续功能提案](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md)中启动器默认允许上报的规则;其余能力仍处于提案状态。 -带版本的 Web 欢迎通知说明会话日志上传默认关闭,并将 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 列为两种显式启用选项。其版本随这项重要的隐私声明一同变更,使每个 profile 都确认当前文案。 +带版本的 Web 欢迎通知说明会话日志上传默认关闭,将 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 列为两种显式启用选项,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测。其版本随这项重要的隐私声明一同变更,使每个 profile 都确认当前文案。 ## 考虑过的替代方案 diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 3366f440bb..961fb0de13 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: 4fa8f0a854285e42fec415e4bcd7182dde745e2a -README.zh.md: e923da7ce27d5bc6a25ee44269acfd68aeb74983 +README.md: d8578a7fbd451c1b7ec54dadeb3d391d597cc18e +README.zh.md: 246c04193e79f46f1e8035c6a40f55a20f1d0c26 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 4fa8f0a854..d8578a7fbd 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -6,7 +6,7 @@ Settings ownerless-copy and product-onboarding plugin: registers everything on t A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read. -`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice states that session telemetry is disabled by default and names the `FEEDBACK_ONLY` and `FULL` opt-in modes. +`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice states that session telemetry is disabled by default, names the `FEEDBACK_ONLY` and `FULL` opt-in modes, and discloses that `FULL` also enables dsh-sdk command telemetry. ## Model Experience diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index e923da7ce2..246c04193e 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -6,7 +6,7 @@ 回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权设置读取。 -`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在用户设置 seam 中注册 `ui-onboarding`。回环浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非回环浏览器不能访问受保护的设置 API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知说明会话遥测默认禁用,并列出 `FEEDBACK_ONLY` 和 `FULL` 两种显式启用模式。 +`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在用户设置 seam 中注册 `ui-onboarding`。回环浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非回环浏览器不能访问受保护的设置 API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知说明会话遥测默认禁用,列出 `FEEDBACK_ONLY` 和 `FULL` 两种显式启用模式,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测。 ## 模型体验 From e92cfc770a10dcb3bb22a9aa2f67a1b3ae6770d5 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 12:16:32 +0800 Subject: [PATCH 06/61] docs: unify conjunction in FULL telemetry disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use '以及' consistently in README.zh.md and the translation snapshot to match onboarding-copy; re-record README pairing hash. --- README.i18n.yaml | 2 +- README.zh.md | 2 +- .../translation-prompt-v4/request-response.expected.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index a6a4b85c44..3b4d706e5d 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md README.md: e662caa94542d86bcc9dcab652b5932a89751b9a -README.zh.md: e4dd46ea88410b71498b7d3bbd461e4f5e4e61c6 +README.zh.md: 4539a730ca0de5c714384d8eea2f993fbe2f6a6e diff --git a/README.zh.md b/README.zh.md index e4dd46ea88..4539a730ca 100644 --- a/README.zh.md +++ b/README.zh.md @@ -10,7 +10,7 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 DeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。 -Session Log 默认留在本地。设置 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 可仅在提交反馈时共享 Session Log,设置 `DSH_TELEMETRY_MODE=FULL` 可持续上传;`FULL` 同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果和脱敏后的项目配置。请通过内部企业微信群反馈问题和建议。 +Session Log 默认留在本地。设置 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 可仅在提交反馈时共享 Session Log,设置 `DSH_TELEMETRY_MODE=FULL` 可持续上传;`FULL` 同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果以及脱敏后的项目配置。请通过内部企业微信群反馈问题和建议。 ## 安装 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index bd18aa0b1e..587f212120 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -12,7 +12,7 @@ }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\nSession Log 默认留在本地。设置 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 可仅在提交反馈时共享 Session Log,设置 `DSH_TELEMETRY_MODE=FULL` 可持续上传;`FULL` 同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果和脱敏后的项目配置。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\nSession Log 默认留在本地。设置 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 可仅在提交反馈时共享 Session Log,设置 `DSH_TELEMETRY_MODE=FULL` 可持续上传;`FULL` 同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果以及脱敏后的项目配置。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", From ec236b273edaffafa63219609ac66b194655f2f3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 14:48:02 +0800 Subject: [PATCH 07/61] fix(telemetry): freeze launcher consent before running a command dsh-sdk resolved launcher telemetry consent in the finally block, after startSDK had already loaded the project .env into process.env, so a project file or project code could grant reporting of its own cordis.yml and package.json. Freeze the decision from the launching environment before dispatch and pass it to the reporter; an unsupported mode denies instead of throwing because telemetry may never change a command result. Configuration source ownership denies the whole DSH_* namespace to discovered files, so the launcher must not read a mutated environment. --- ...2026-08-10-telemetry-default-off.i18n.yaml | 4 +- .../2026-08-10-telemetry-default-off.md | 2 +- .../2026-08-10-telemetry-default-off.zh.md | 2 +- packages/scaffold/scripts/src/command.ts | 14 +++-- packages/scaffold/scripts/src/telemetry.ts | 31 +++++++++-- .../scaffold/scripts/tests/scripts.spec.ts | 54 ++++++++++++++++++- packages/scaffold/telemetry/README.i18n.yaml | 4 +- packages/scaffold/telemetry/README.md | 2 +- packages/scaffold/telemetry/README.zh.md | 2 +- 9 files changed, 99 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 7274b41848..7c4995a88d 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.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-08-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: a56b08820b4bf4f9ffa91944b1d9c961d1f3826f -2026-08-10-telemetry-default-off.zh.md: 90b0ae5a7627dcdddf68c11e59e52b80b9c807f1 +2026-08-10-telemetry-default-off.md: 4bda346c2b05a94106eb5658c3ee558a4b32407f +2026-08-10-telemetry-default-off.zh.md: 706f2c18fbbf226e0357fa99bf3fd61c39fce08a diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index a56b08820b..4bda346c2b 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -12,7 +12,7 @@ DeepSeek Harness has two outbound telemetry feeds. During internal testing, the Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. -The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. This rule supersedes only the default-on launcher consent in the [SDK follow-up proposal](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md); its other capabilities remain proposed. +The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule supersedes only the default-on launcher consent in the [SDK follow-up proposal](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md); its other capabilities remain proposed. The versioned Web welcome notice states that Session Log upload is off by default, names `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` as the two opt-in choices, and discloses that `FULL` also enables dsh-sdk command telemetry. Its version changes with that material privacy statement so every profile acknowledges the current copy. diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index 90b0ae5a76..706f2c18fb 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础 两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.md)继续负责 endpoint、批处理节奏和退出排空设置。 -dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。此规则仅取代 [SDK 后续功能提案](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md)中启动器默认允许上报的规则;其余能力仍处于提案状态。 +dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则仅取代 [SDK 后续功能提案](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md)中启动器默认允许上报的规则;其余能力仍处于提案状态。 带版本的 Web 欢迎通知说明会话日志上传默认关闭,将 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 列为两种显式启用选项,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测。其版本随这项重要的隐私声明一同变更,使每个 profile 都确认当前文案。 diff --git a/packages/scaffold/scripts/src/command.ts b/packages/scaffold/scripts/src/command.ts index ebf9b06846..e1685e4102 100644 --- a/packages/scaffold/scripts/src/command.ts +++ b/packages/scaffold/scripts/src/command.ts @@ -9,7 +9,12 @@ import { runProjectBuild } from './build.ts' import { runConfigCommand, type ConfigCommandContext } from './config.ts' import { runCreatePluginCommand } from './create-plugin.ts' import { runSDK } from './runtime.ts' -import { reportCommandTelemetry, type CommandTelemetryEvent } from './telemetry.ts' +import { + freezeTelemetryConsent, + reportCommandTelemetry, + type CommandTelemetryDeps, + type CommandTelemetryEvent, +} from './telemetry.ts' import { DSH_SDK_TEMPLATES } from './templates/dsh-sdk-templates.ts' /** Injectable process and command boundaries used by the dsh-sdk bin. */ @@ -22,7 +27,7 @@ export interface DshSdkCommandContext extends ConfigCommandContext { build?: typeof runProjectBuild config?: typeof runConfigCommand createPlugin?: typeof runCreatePluginCommand - telemetry?: (event: CommandTelemetryEvent) => Promise + telemetry?: (event: CommandTelemetryEvent, deps?: CommandTelemetryDeps) => Promise } /** Run one parsed dsh-sdk command and return its process exit code. */ @@ -36,6 +41,9 @@ export async function runDshSdkCommand( }, ): Promise { const startedAt = Date.now() + // Freeze consent from the launching environment: a command may load a project + // `.env` or mutate `process.env`, and neither may grant launcher reporting. + const consent = freezeTelemetryConsent() let command: string | undefined let success = true try { @@ -70,7 +78,7 @@ export async function runDshSdkCommand( if (command !== undefined) { /* v8 ignore next -- production telemetry wiring is exercised by the built-bin smoke */ const telemetry = context.telemetry ?? reportCommandTelemetry - await telemetry({ command, cwd: context.cwd, durationMs: Date.now() - startedAt, success }) + await telemetry({ command, cwd: context.cwd, durationMs: Date.now() - startedAt, success }, { consent }) } } } diff --git a/packages/scaffold/scripts/src/telemetry.ts b/packages/scaffold/scripts/src/telemetry.ts index 193cdf1fc7..37ad56c72a 100644 --- a/packages/scaffold/scripts/src/telemetry.ts +++ b/packages/scaffold/scripts/src/telemetry.ts @@ -27,14 +27,37 @@ export interface CommandTelemetryEvent { /** Injectable consent and delivery hooks for tests. */ export interface CommandTelemetryDeps { + /** + * Consent frozen from the launching environment before the command ran. When + * present it is authoritative: the environment a command mutated cannot grant + * or revoke reporting. + */ + consent?: ConsentDecision resolve?: () => ConsentDecision | Promise reporter?: Pick } /** - * Resolve the shared telemetry mode and, when allowed, assemble and send one - * telemetry event, draining in-flight sends before returning. Swallows every - * error so telemetry can never change a command's result. + * Freeze launcher telemetry consent from the launching environment before any + * command runs. A command may load a project `.env` or mutate `process.env`, so + * resolving consent afterwards would let project files or project code enable + * reporting of their own configuration. An unsupported mode denies rather than + * throwing, because telemetry may never change a command's result. + * @param env - Environment containing `DSH_TELEMETRY_MODE`; defaults to `process.env`. + * @returns The consent decision to apply after the command finishes. + */ +export function freezeTelemetryConsent(env: NodeJS.ProcessEnv = process.env): ConsentDecision { + try { + return resolveTelemetryConsent(env) + } catch { + return { allowed: false, reason: 'DISABLED' } + } +} + +/** + * Assemble and send one telemetry event when consent allows, draining in-flight + * sends before returning. Swallows every error so telemetry can never change a + * command's result. * @param event - the command lifecycle facts. * @param deps - Consent and delivery hooks; defaults hit the real endpoint. */ @@ -44,7 +67,7 @@ export async function reportCommandTelemetry( ): Promise { try { /* v8 ignore next -- the production resolver is exercised by its owning tests */ - const consent = await (deps.resolve?.() ?? resolveTelemetryConsent()) + const consent = deps.consent ?? await (deps.resolve?.() ?? resolveTelemetryConsent()) if (!consent.allowed) return const payload = await buildTelemetryPayload({ command: event.command, diff --git a/packages/scaffold/scripts/tests/scripts.spec.ts b/packages/scaffold/scripts/tests/scripts.spec.ts index d1f6cf81d1..396b6b7ee7 100644 --- a/packages/scaffold/scripts/tests/scripts.spec.ts +++ b/packages/scaffold/scripts/tests/scripts.spec.ts @@ -32,7 +32,12 @@ import { runDshSdkCommand, type DshSdkCommandContext } from '../src/command.ts' import { runConfigCommand } from '../src/config.ts' import { ConfigWorkflow, type ConfigPlan } from '../src/config/config-workflow.ts' import { runCreatePluginCommand } from '../src/create-plugin.ts' -import { reportCommandTelemetry, type CommandTelemetryEvent } from '../src/telemetry.ts' +import { + freezeTelemetryConsent, + reportCommandTelemetry, + type CommandTelemetryDeps, + type CommandTelemetryEvent, +} from '../src/telemetry.ts' import { initialize, resolve as resolveLocalPlugin } from '../src/local-plugin-loader-hooks.ts' const temporary: string[] = [] @@ -626,6 +631,53 @@ describe('command telemetry', () => { expect(sent).toHaveLength(1) }) + it('prefers frozen consent over resolving the mutated environment', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-telemetry-')) + temporary.push(dir) + const sent: unknown[] = [] + const reporter = { report: () => { sent.push(1) }, flush: async () => {} } + let resolved = 0 + await reportCommandTelemetry( + { command: 'start', cwd: dir, durationMs: 5, success: true }, + { + consent: { allowed: false, reason: 'DISABLED' }, + resolve: () => { resolved += 1; return { allowed: true, reason: 'FULL' } }, + reporter, + }, + ) + expect(sent).toHaveLength(0) + expect(resolved).toBe(0) + }) + + it('freezes consent from the launching environment and denies unsupported modes', () => { + expect(freezeTelemetryConsent({ DSH_TELEMETRY_MODE: 'FULL' })).toEqual({ allowed: true, reason: 'FULL' }) + expect(freezeTelemetryConsent({ DSH_TELEMETRY_MODE: 'FEEDBACK_ONLY' })) + .toEqual({ allowed: false, reason: 'FEEDBACK_ONLY' }) + expect(freezeTelemetryConsent({})).toEqual({ allowed: false, reason: 'DISABLED' }) + expect(freezeTelemetryConsent({ DSH_TELEMETRY_MODE: '' })).toEqual({ allowed: false, reason: 'DISABLED' }) + expect(freezeTelemetryConsent({ DSH_TELEMETRY_MODE: 'nonsense' })) + .toEqual({ allowed: false, reason: 'DISABLED' }) + }) + + it('denies reporting when the command itself sets the mode', async () => { + const project = await committedProject() + const previous = process.env.DSH_TELEMETRY_MODE + delete process.env.DSH_TELEMETRY_MODE + const seen: (CommandTelemetryDeps | undefined)[] = [] + const context = commandContext(project.root) + context.telemetry = async (_event, deps) => { seen.push(deps) } + // A project `.env` load or project code mutating the environment mid-command. + context.build = async () => { process.env.DSH_TELEMETRY_MODE = 'FULL' } + try { + await expect(runDshSdkCommand(['build'], context)).resolves.toBe(0) + } finally { + if (previous === undefined) delete process.env.DSH_TELEMETRY_MODE + else process.env.DSH_TELEMETRY_MODE = previous + } + expect(seen).toHaveLength(1) + expect(seen[0]?.consent).toEqual({ allowed: false, reason: 'DISABLED' }) + }) + it('emits a telemetry event carrying each command outcome', async () => { const project = await committedProject() const events: CommandTelemetryEvent[] = [] diff --git a/packages/scaffold/telemetry/README.i18n.yaml b/packages/scaffold/telemetry/README.i18n.yaml index c44209d617..09c7f3732d 100644 --- a/packages/scaffold/telemetry/README.i18n.yaml +++ b/packages/scaffold/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 packages/scaffold/telemetry/README.md -README.md: 670937d70dd2cabc0c77306a6239caa23123ae6f -README.zh.md: 6bbd17a46fe9828b3722c5703c1484eafebb8540 +README.md: e923632d05a941722a1b91740461d818ccaa6bb5 +README.zh.md: 5537a660f42df52616a968746dcbd122c16bec04 diff --git a/packages/scaffold/telemetry/README.md b/packages/scaffold/telemetry/README.md index 670937d70d..e923632d05 100644 --- a/packages/scaffold/telemetry/README.md +++ b/packages/scaffold/telemetry/README.md @@ -12,7 +12,7 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li | `getOrCreateAnonymousId` | Random UUID persisted in the harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`$DSH_HOME` > `~/.dsh`), scoped to that home rather than the machine, never derived from git. | | `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. | -`DSH_TELEMETRY_MODE` is the single positive consent setting for session and launcher telemetry. `FULL` enables this launcher feed; `FEEDBACK_ONLY` keeps command telemetry off and permits only feedback-triggered Session Log sharing; every other supported state keeps this feed off. +`DSH_TELEMETRY_MODE` is the single positive consent setting for session and launcher telemetry. `FULL` enables this launcher feed; `FEEDBACK_ONLY` keeps command telemetry off and permits only feedback-triggered Session Log sharing; every other supported state keeps this feed off. Callers must resolve consent from the launching environment before running a command, because a command may load a project `.env` or mutate `process.env`; the launcher wiring in `@deepseek-ai/dsh-scripts` freezes the decision up front. The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`). diff --git a/packages/scaffold/telemetry/README.zh.md b/packages/scaffold/telemetry/README.zh.md index 6bbd17a46f..5537a660f4 100644 --- a/packages/scaffold/telemetry/README.zh.md +++ b/packages/scaffold/telemetry/README.zh.md @@ -12,7 +12,7 @@ | `getOrCreateAnonymousId` | 将随机 UUID 持久化到 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 harness home(`$DSH_HOME` > `~/.dsh`);其范围限定为该 home,而不是整台机器,且绝不从 git 派生。 | | `TelemetryReporter` | 即发即弃发送:`report()` 绝不阻塞或抛出;无论经过哪条路径,发送操作最终都会结束;`flush()` 可以在上限内排空进行中的发送。 | -`DSH_TELEMETRY_MODE` 是会话与启动器 telemetry 的唯一正向授权配置。`FULL` 启用该启动器数据流;`FEEDBACK_ONLY` 保持命令 telemetry 关闭,只允许由反馈触发的 Session Log 共享;其他受支持的状态都会保持该数据流关闭。 +`DSH_TELEMETRY_MODE` 是会话与启动器 telemetry 的唯一正向授权配置。`FULL` 启用该启动器数据流;`FEEDBACK_ONLY` 保持命令 telemetry 关闭,只允许由反馈触发的 Session Log 共享;其他受支持的状态都会保持该数据流关闭。调用方必须在执行命令前从启动环境解析授权,因为命令可能加载项目 `.env` 或修改 `process.env`;`@deepseek-ai/dsh-scripts` 中的启动器接线会在命令执行前冻结该决定。 收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`)。 From adcba3ed961547c6fde2ed4cbedb5a624532eb36 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 15:23:19 +0800 Subject: [PATCH 08/61] refactor(telemetry): keep frozen-consent precedence coverage-enforced Split the deferred resolver into its own function so the v8 ignore covers only the production resolver arm, leaving the security-relevant deps.consent precedence branch enforced at 100% branch coverage. Assert the frozen-allow path so both sides of that branch are exercised, and document the caller obligation on reportCommandTelemetry and resolveTelemetryConsent. --- packages/scaffold/scripts/src/telemetry.ts | 20 ++++++++++++++++--- .../scaffold/scripts/tests/scripts.spec.ts | 12 +++++++++++ .../telemetry/src/consent-resolver.ts | 5 +++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/scaffold/scripts/src/telemetry.ts b/packages/scaffold/scripts/src/telemetry.ts index 37ad56c72a..9533c8e191 100644 --- a/packages/scaffold/scripts/src/telemetry.ts +++ b/packages/scaffold/scripts/src/telemetry.ts @@ -59,15 +59,18 @@ export function freezeTelemetryConsent(env: NodeJS.ProcessEnv = process.env): Co * sends before returning. Swallows every error so telemetry can never change a * command's result. * @param event - the command lifecycle facts. - * @param deps - Consent and delivery hooks; defaults hit the real endpoint. + * @param deps - Consent and delivery hooks; defaults hit the real endpoint. Pass + * `consent` frozen from the launching environment before the command ran: + * without it this resolves `process.env` as it stands now, which a project + * `.env` or project code may already have changed. */ export async function reportCommandTelemetry( event: CommandTelemetryEvent, deps: CommandTelemetryDeps = {}, ): Promise { try { - /* v8 ignore next -- the production resolver is exercised by its owning tests */ - const consent = deps.consent ?? await (deps.resolve?.() ?? resolveTelemetryConsent()) + const consent = deps.consent ?? await resolveDeferredConsent(deps) + /* v8 ignore next -- v8 mis-accounts this early return's implicit else; both outcomes are asserted */ if (!consent.allowed) return const payload = await buildTelemetryPayload({ command: event.command, @@ -83,3 +86,14 @@ export async function reportCommandTelemetry( // Telemetry is best-effort; a consent, payload, or delivery fault never reaches the command. } } + +/** + * Resolve consent for a caller that supplied no frozen decision, reading the + * environment as it stands after the command ran. + * @param deps - the caller's consent hooks. + * @returns the resolved consent decision. + */ +async function resolveDeferredConsent(deps: CommandTelemetryDeps): Promise { + /* v8 ignore next -- the production resolver is exercised by its owning tests */ + return await (deps.resolve?.() ?? resolveTelemetryConsent()) +} diff --git a/packages/scaffold/scripts/tests/scripts.spec.ts b/packages/scaffold/scripts/tests/scripts.spec.ts index 396b6b7ee7..50a11cdda9 100644 --- a/packages/scaffold/scripts/tests/scripts.spec.ts +++ b/packages/scaffold/scripts/tests/scripts.spec.ts @@ -647,6 +647,18 @@ describe('command telemetry', () => { ) expect(sent).toHaveLength(0) expect(resolved).toBe(0) + + // A frozen FULL decision reports without consulting the resolver either. + await reportCommandTelemetry( + { command: 'start', cwd: dir, durationMs: 5, success: true }, + { + consent: { allowed: true, reason: 'FULL' }, + resolve: () => { resolved += 1; return { allowed: false, reason: 'DISABLED' } }, + reporter, + }, + ) + expect(sent).toHaveLength(1) + expect(resolved).toBe(0) }) it('freezes consent from the launching environment and denies unsupported modes', () => { diff --git a/packages/scaffold/telemetry/src/consent-resolver.ts b/packages/scaffold/telemetry/src/consent-resolver.ts index 190481b8ed..db2f8c81a4 100644 --- a/packages/scaffold/telemetry/src/consent-resolver.ts +++ b/packages/scaffold/telemetry/src/consent-resolver.ts @@ -24,6 +24,11 @@ export interface ConsentDecision { /** * Resolve launcher telemetry consent from the shared telemetry mode. + * + * Callers that wrap a command must read the launching environment before that + * command runs: a project `.env` load or project code can change + * `process.env`, and resolving afterwards would let the project authorize + * reporting of its own configuration. * @param env - Environment containing `DSH_TELEMETRY_MODE`; defaults to `process.env`. * @returns Whether launcher telemetry may report and the resolved mode. */ From ebe932e24c02b2afefd416a9ddee580cc6d8e7cd Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 11 Aug 2026 15:59:43 +0800 Subject: [PATCH 09/61] fix(subprocess): clean managed processes on host exit --- ...chronous-subprocess-exit-cleanup.i18n.yaml | 6 + ...-11-synchronous-subprocess-exit-cleanup.md | 51 ++++++ ...-synchronous-subprocess-exit-cleanup.zh.md | 51 ++++++ .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 4 +- .../subprocess/subprocess-local/README.zh.md | 4 +- .../subprocess/subprocess-local/package.json | 1 + .../subprocess/subprocess-local/src/index.ts | 89 ++++++--- .../subprocess/subprocess-local/src/spawn.ts | 16 +- .../subprocess-local/src/terminal.ts | 37 ++++ .../tests/fixtures/managed-tree.ts | 16 ++ .../tests/fixtures/process-exit-host.ts | 79 ++++++++ .../subprocess-local/tests/local.spec.ts | 95 ++++++++++ .../tests/process-exit.spec.ts | 169 ++++++++++++++++++ .../subprocess-local/tests/spawn.spec.ts | 36 ++++ .../subprocess-local/tests/terminal.spec.ts | 80 +++++++++ pnpm-lock.yaml | 3 + vitest.config.ts | 25 ++- 18 files changed, 728 insertions(+), 38 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md create mode 100644 packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts create mode 100644 packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts create mode 100644 packages/subprocess/subprocess-local/tests/process-exit.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml new file mode 100644 index 0000000000..622b1b983c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.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-08-11-synchronous-subprocess-exit-cleanup.md +2026-08-11-synchronous-subprocess-exit-cleanup.md: e120f87350f1acd28f7f449791f01b6c0a57674e +2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 6cf7620ed006c22b79b523242fa5f1429c58a2f3 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md new file mode 100644 index 0000000000..e120f87350 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md @@ -0,0 +1,51 @@ +# Agent Note: Synchronous cleanup of managed subprocesses on host exit + +Status: implemented + +English | [中文](2026-08-11-synchronous-subprocess-exit-cleanup.zh.md) + +## Problem + +The local subprocess provider owns ordinary detached process trees and terminal sessions, but it previously reached them only through asynchronous Cordis disposal. A fatal launcher may call `process.exit()` before that disposal finishes: the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) waits at most two seconds, while a local process can have a longer termination grace. Once Node enters its synchronous exit phase, pending promises and escalation timers do not continue, so a TERM-resistant child can outlive the host and keep CPU, memory, or ports. Some ACP, JSON-RPC, and SDK entry points also have no root release callback. + +The public subprocess seam correctly promises awaited quiescence during normal disposal. The defect is a separate final host-exit path below that seam, not a reason to weaken the normal lifecycle or duplicate process ownership in every launcher. + +## Decision + +`LocalSubprocessService` installs one synchronous Node `exit` listener in its Cordis effect. The same effect removes the listener only after normal disposal settles. Ordinary and terminal handles remain in the service's existing live sets while asynchronous cleanup is pending, so a shorter outer exit bound still sees and force-terminates them. If awaited disposal reports a cleanup failure, the service invokes the same synchronous final operations before clearing the sets and removing the listener. + +The listener uses local-only final operations that are absent from the public `SubprocessHandle` and `SubprocessTerminalHandle` interfaces: + +- An ordinary handle immediately sends SIGKILL to its detached POSIX process group or runs synchronous `taskkill /PID /T /F` on Windows. +- A terminal handle synchronously signals every captured and currently observable descendant with SIGKILL, kills the PTY root, then rescans once for members that became observable during that boundary. +- The service contains each target's failure and continues with the remaining handles. The callback creates no promise or timer, writes no diagnostic, and does not change the original exit code or error. + +Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subprocess-seam.md) terminate-and-join path: ordinary trees receive TERM, the configured grace, then KILL, and every ordinary or terminal cleanup is awaited to quiescence. The synchronous path requests final termination but does not publish a completion result or claim the OS tree is already gone when the callback returns. Remote providers retain their own sandbox ownership and do not inherit a local Node listener. + +| Host path | Local provider action | Completion evidence | +| --- | --- | --- | +| Normal Cordis disposal | Cooperative termination, bounded escalation, and awaited ordinary/terminal cleanup | Every owned handle reaches quiescence before disposal settles | +| `process.exit()`, default uncaught exception, or default unhandled rejection | Synchronous final signals against the service's current live sets | External observation after the host exits | +| `SIGKILL`, fatal OOM, `process.abort()`, native crash, or power loss | No in-process action can run | External supervisor, container, or OS ownership is required | + +## Verification + +A parent test starts an isolated TypeScript host through the repository source launcher, waits until exact root and descendant process identities are observable, then allows the host to take each fatal path. Direct exit, default uncaught exception, and default unhandled rejection cover ordinary TERM-resistant trees; direct exit also covers a real terminal root and descendant. The parent asserts the original host exit category and waits for every recorded process to disappear, while failure cleanup targets only recorded identities or the recorded Windows tree. + +Unit evidence pins synchronous POSIX group and Windows taskkill delivery, terminal scans before and after the PTY root kill, repeated finalization, per-target failure containment, normal TERM-to-KILL disposal, live-set retention during pending disposal, and listener removal after disposal. + +## Alternatives considered + +**Rely only on launcher release callbacks.** Rejected because not every entry point supplies one, and a bounded release can still end before the subprocess provider's grace and timers complete. + +**Call the existing asynchronous `terminate()` methods from the `exit` listener.** Rejected because Node does not await exit listeners; promises, timers, output draining, and quiescence polling cannot finish after the callback returns. + +**Add a public raw `forceKill()` operation to subprocess handles.** Rejected because consumers need one cooperative termination contract. Immediate final termination is an implementation responsibility used only by the local service's host-exit owner. + +**Delegate every failure mode to an external supervisor.** Rejected as the only solution because Node exposes a reliable synchronous callback for several common fatal paths and the provider already owns the exact targets. External ownership remains necessary when JavaScript cannot run. + +## Consequences + +Each active local subprocess service contributes one process-global exit listener, removed with the service effect. Fatal exit gives up grace, output draining, and an in-process quiescence proof in exchange for issuing the strongest available local termination before the host disappears. Normal disposal keeps those guarantees and costs unchanged. + +The listener cannot cover failures that do not execute JavaScript, and it cannot discover a terminal descendant that escaped before the provider ever observed it; that separate ownership gap remains tracked by Issue #1726. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md new file mode 100644 index 0000000000..6cf7620ed0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 宿主退出时同步清理受管子进程 + +Status: implemented + +[English](2026-08-11-synchronous-subprocess-exit-cleanup.md) | 中文 + +## Problem + +本地 subprocess provider拥有普通 detached进程树和 terminal session,但此前只能通过异步 Cordis dispose触及它们。致命 launcher可能在 dispose完成前调用 `process.exit()`:[fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md)最多等待两秒,而本地进程可以拥有更长的终止宽限期。Node进入同步退出阶段后,待处理的 Promise与升级 timer不会继续执行,因此忽略 TERM的子进程可能比宿主存活更久,继续占用 CPU、内存或端口。部分 ACP、JSON-RPC和 SDK入口也没有 root release回调。 + +公共 subprocess seam在正常 dispose期间承诺等待完全停稳,这项承诺是正确的。缺陷属于 seam之下另一条最终宿主退出路径,不应削弱正常生命周期,也不应让每个 launcher重复保存进程所有权。 + +## Decision + +`LocalSubprocessService`在自身 Cordis effect中安装一个同步 Node `exit` listener。只有正常 dispose结算后,同一 effect才移除该 listener。异步清理仍在等待时,普通和 terminal handle继续保留在服务已有的存活集合中,因此更短的外层退出上限仍能看到并强制终止它们。等待中的 dispose报告清理失败时,服务会在清空集合并移除 listener前调用同一组同步最终操作。 + +该 listener使用本地实现私有的最终操作;公共 `SubprocessHandle`和 `SubprocessTerminalHandle`接口不包含这些操作: + +- 普通 handle立即向 detached POSIX进程组发送 SIGKILL,或在 Windows同步运行 `taskkill /PID /T /F`。 +- Terminal handle同步向全部已捕获及当前可观察的后代发送 SIGKILL,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。 +- 服务分别包含每个目标的失败并继续处理其余 handle。回调不会创建 Promise或 timer,不写诊断,也不改变原始退出码或错误。 + +正常 dispose继续使用[subprocess seam](../architecture/2026-07-26-subprocess-seam.md)的先终止再等待退出路径:普通进程树先接收 TERM,经过配置的宽限期后再接收 KILL,并等待每个普通或 terminal清理达到完全停稳。同步路径只请求最终终止,不发布完成结果,也不声称回调返回时 OS进程树已经消失。远程 provider继续由其 sandbox独立拥有,不继承本地 Node listener。 + +| 宿主路径 | 本地 provider动作 | 完成证据 | +| --- | --- | --- | +| 正常 Cordis dispose | 协作式终止、有界升级,并等待普通/terminal清理 | dispose结算前,每个自有 handle均达到完全停稳 | +| `process.exit()`、默认未捕获异常或默认未处理 rejection | 对服务当前存活集合发送同步最终信号 | 宿主退出后的外部观察 | +| `SIGKILL`、fatal OOM、`process.abort()`、native crash或断电 | 进程内操作无法运行 | 必须由外部 supervisor、容器或 OS所有权负责 | + +## Verification + +父测试通过仓库 source launcher启动隔离的 TypeScript宿主,等待精确 root与后代进程身份可观察后,再允许宿主进入各条致命路径。直接退出、默认未捕获异常和默认未处理 rejection覆盖忽略 TERM的普通进程树;直接退出还覆盖真实 terminal root与后代。父测试断言原始宿主退出类别,并等待所有已记录进程消失;失败清理只针对已记录身份或已记录的 Windows进程树。 + +单元证据固定同步 POSIX进程组与 Windows taskkill投递、PTY root终止前后的 terminal扫描、重复最终清理、逐目标失败包含、正常 TERM到 KILL dispose、dispose等待期间保留存活集合,以及 dispose后移除 listener。 + +## Alternatives considered + +**只依赖 launcher release回调。** 拒绝,因为不是每个入口都会提供该回调,而且有界 release仍可能在 subprocess provider的宽限期与 timer完成前结束。 + +**在 `exit` listener中调用现有异步 `terminate()`。** 拒绝,因为 Node不会等待 exit listener;回调返回后,Promise、timer、输出排空与停稳轮询都无法完成。 + +**向公共 subprocess handle增加 raw `forceKill()`操作。** 拒绝,因为消费方只需要一项协作式终止约定。立即最终终止属于实现职责,只由本地服务的宿主退出 owner使用。 + +**把所有故障模式交给外部 supervisor。** 不接受将其作为唯一方案,因为 Node为几条常见致命路径提供可靠的同步回调,而 provider已经拥有精确目标。JavaScript无法运行时仍必须依赖外部所有权。 + +## Consequences + +每个有效的本地 subprocess service都会贡献一个进程全局 exit listener,并随服务 effect移除。致命退出放弃宽限、输出排空与进程内停稳证明,以换取宿主消失前发出本地可用的最强终止操作。正常 dispose的保证与成本保持不变。 + +listener无法覆盖不执行 JavaScript的故障,也无法发现 provider首次观察前已经逃逸的 terminal后代;该独立所有权缺口仍由 Issue #1726跟踪。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index a9be76e0c0..ca13399e55 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 packages/subprocess/subprocess-local/README.md -README.md: 15fc001b9fcf2eadd7b37415fd92386565b649c3 -README.zh.md: 48b662302183f07d514f975089d4b49bd17c8e69 +README.md: 40c01caa3daf00d490e935bd828e15ce8fa7fb68 +README.zh.md: edafa0e030cd2af3308bcdea3a0e190de4448125 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 15fc001b9f..40c01caa3d 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -12,7 +12,8 @@ Local Service provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **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. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. - **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. -- **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. +- **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes. +- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). ## Model Experience @@ -27,6 +28,7 @@ No direct invalidation; the named consumers own any request-prefix changes. - **Windows tree support is best-effort** — 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. - **Terminal process inspection is Linux/macOS only** — the terminal primitive fails when its inspector has no supported platform implementation; Linux exact probes cover x64 and arm64, while macOS uses `ps` snapshots. - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. +- **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event. `SIGKILL`, fatal OOM, `process.abort()`, native crashes, power loss, and any failure that cannot run JavaScript require an external supervisor, container init, or equivalent OS owner. - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) 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/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 48b6623021..edafa0e030 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -12,7 +12,8 @@ - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该能力入口被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 - **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 -- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 +- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn失败的句柄会在整棵进程树或 terminal session清理完成后离开存活集合。 +- **同步宿主退出最终清理**:服务 effect仍有效时,Node `exit` listener会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX进程组发送 SIGKILL、在 Windows运行 `taskkill /T /F`,并在终止 PTY root前后同步向已捕获及当前可观察的 terminal身份发送信号;它们不会创建 Promise或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)。 ## 模型体验 @@ -27,6 +28,7 @@ - **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。 - **终端进程检查仅支持 Linux/macOS**:检查器没有受支持的平台实现时,终端原语会失败;Linux 精确探针覆盖 x64 与 arm64,macOS 则使用 `ps` 快照。 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 +- **进程内清理要求退出阶段仍能执行 JavaScript**:直接 `process.exit()`、默认未捕获异常和默认未处理 rejection会发出 Node同步 `exit`事件。`SIGKILL`、fatal OOM、`process.abort()`、native crash、断电,以及任何无法运行 JavaScript的故障,都需要外部 supervisor、容器 init或等价的 OS所有者负责。 - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 - **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 12f47c0400..fb27fc80c4 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -46,6 +46,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 5242986b3b..68e781b2af 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -1,7 +1,8 @@ /** * Local Service provider for the subprocess capability 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 + * process tree with the spec's per-stream stdio dispositions. Normal disposal + * terminates and joins live trees; Node's synchronous exit phase force-stops + * any trees the service still owns. It has no config: every disposition and * limit arrives on the spec, so the deployment-varying choices stay with the * caller's config (the bash executor's, the LSP host's, …). * @module @deepseek-ai/dsh-subprocess-local @@ -21,7 +22,7 @@ import type { SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import { childEnv, spawnSubprocess } from './spawn.ts' -import type { SpawnInternals } from './spawn.ts' +import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts' import { createProcessInspector } from './process-inspector.ts' import type { ProcessInspector } from './process-inspector.ts' import { LocalTerminalHandle } from './terminal.ts' @@ -30,13 +31,14 @@ import { LocalTerminalHandle } from './terminal.ts' * Local subprocess service: detached process trees, Node-shaped stdio * dispositions (raw pipes, inherit, bounded tail-keep collection with spill * files), credential-scrubbed environment, and tree-scoped signalling with - * SIGTERM→grace→SIGKILL escalation. + * SIGTERM→grace→SIGKILL escalation, plus synchronous final termination during + * JavaScript-observable host exit. */ export class LocalSubprocessService extends SubprocessService { - /** Live handles retained only so disposal can terminate and join them. */ - private live = new Set() - /** Live terminal sessions retained through whole-session quiescence. */ - private terminals = new Set() + /** Live handles retained for normal disposal and synchronous host-exit finalization. */ + private live = new Set() + /** Live terminals retained through normal quiescence or host-exit finalization. */ + private terminals = new Set() /** Test hook: spill and platform knobs forwarded to spawnSubprocess. */ internals: SpawnInternals = {} /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */ @@ -44,30 +46,61 @@ export class LocalSubprocessService extends SubprocessService { constructor(ctx: Context) { super(ctx) - ctx.effect(() => async () => { - // 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(() => {}).then(() => handle.waitForExit())) + ctx.effect(() => { + const onHostExit = (): void => { this.terminateForHostExit() } + process.on('exit', onHostExit) + return async () => { + try { + await this.disposeManagedProcesses() + } finally { + process.off('exit', onHostExit) + } } - for (const terminal of this.terminals) { - pending.push(terminal.terminate()) - } - this.live.clear() - this.terminals.clear() - const outcomes = await Promise.allSettled(pending) - const failures = outcomes.flatMap(outcome => outcome.status === 'rejected' - ? [outcome.reason as unknown] - : []) - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed') }, 'local subprocess teardown') } + private terminateForHostExit(): void { + for (const handle of this.live) { + try { + handle.terminateForHostExit() + } catch (_ordinaryTreeTerminationFailed) { + // Host exit cannot await or report one target; continue with the rest. + } + } + for (const terminal of this.terminals) { + try { + terminal.terminateForHostExit() + } catch (_terminalTerminationFailed) { + // One terminal must not prevent final termination of another target. + } + } + } + + private async disposeManagedProcesses(): Promise { + // Terminate (escalating), then await WHOLE-TREE exit — not just the + // direct child's settlement — so even a TERM-trapping descendant cannot + // outlive the fiber. Keep both sets authoritative while these waits are + // pending so a shorter process-level exit bound can still force-kill them. + 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(() => {}).then(() => handle.waitForExit())) + } + for (const terminal of this.terminals) { + pending.push(terminal.terminate()) + } + const outcomes = await Promise.allSettled(pending) + const failures = outcomes.flatMap(outcome => outcome.status === 'rejected' + ? [outcome.reason as unknown] + : []) + if (failures.length > 0) this.terminateForHostExit() + this.live.clear() + this.terminals.clear() + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed') + } + async resolveExecutable( command: string, env?: Readonly>, diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 5b977cacc1..91db98f944 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -58,6 +58,15 @@ export interface SpawnInternals { linuxProcessGroupHasLiveMembers?: (processGroupId: number) => boolean | undefined } +/** + * Local-only extension used by the owning service during Node's synchronous + * host-exit phase. It is intentionally absent from the public subprocess seam. + */ +export interface LocalSubprocessHandle extends SubprocessHandle { + /** Force-terminate the current tree synchronously without starting timers or waits. */ + terminateForHostExit(): void +} + /** * 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 @@ -313,7 +322,7 @@ function signalTree( * @returns live subprocess handle. * @throws when `graceMs` cannot be represented by one Node timer. */ -export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle { +export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): LocalSubprocessHandle { if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) { throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) } @@ -442,6 +451,10 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) } + const terminateForHostExit = (): void => { + kill('SIGKILL') + } + // The caller owns timeout classification; this layer only reacts to abort. const onAbort = (): void => { terminate() } spec.signal?.addEventListener('abort', onAbort, { once: true }) @@ -523,6 +536,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter }, done, terminate, + terminateForHostExit, waitForExit, } } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 11d13a405a..6d818c8a7f 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -110,6 +110,33 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { return cleanup } + /** + * Force-terminate the observable session synchronously during Node's exit + * event. This does not claim quiescence and does not replace terminate(). + */ + terminateForHostExit(): void { + this.forceStopDescendants() + this.forceStopShell() + this.forceStopDescendants() + } + + private forceStopShell(): void { + if (this.exited) return + if (this.rootIdentity !== undefined) { + try { + this.inspector.signalProcess(this.rootIdentity, 'SIGKILL') + } catch (_rootExitedDuringHostExit) { + // Exact identity signalling contains both exit races and PID reuse. + } + return + } + try { + this.terminal.kill('SIGKILL') + } catch (_unidentifiedShellExitedDuringHostExit) { + // Without a captured identity, node-pty is the only root kill primitive. + } + } + private survivors(members: ProcessIdentity[]): ProcessIdentity[] { return members.filter(member => this.inspector.isAlive(member)) } @@ -152,6 +179,16 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { } } + private forceStopDescendants(): void { + let members = this.trackedDescendants + try { + members = this.descendants() + } catch (_processTableUnavailableDuringHostExit) { + // Preserve already-captured identities when a final process-table scan fails. + } + this.signalMembers(members, 'SIGKILL') + } + private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] { const members: ProcessIdentity[] = [] const seen = new Set() diff --git a/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts b/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts new file mode 100644 index 0000000000..31d26b9e39 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts @@ -0,0 +1,16 @@ +import { spawn } from 'node:child_process' +import { writeFile } from 'node:fs/promises' + +const [statePath] = process.argv.slice(2) +if (statePath === undefined) throw new Error('usage: managed-tree.ts ') + +process.on('SIGTERM', () => {}) +process.on('SIGHUP', () => {}) +const descendant = spawn(process.execPath, [ + '-e', + 'process.on("SIGTERM",()=>{});process.on("SIGHUP",()=>{});setInterval(()=>{},60_000)', +], { stdio: 'ignore' }) +if (descendant.pid === undefined) throw new Error('managed descendant did not publish a pid') + +await writeFile(statePath, JSON.stringify({ root: process.pid, descendant: descendant.pid })) +setInterval(() => {}, 60_000) diff --git a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts new file mode 100644 index 0000000000..83b4664cae --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts @@ -0,0 +1,79 @@ +import { access, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' + +const [kind, trigger, root] = process.argv.slice(2) +if ((kind !== 'ordinary' && kind !== 'terminal') + || (trigger !== 'direct' && trigger !== 'uncaught-exception' + && trigger !== 'unhandled-rejection' && trigger !== 'dispose') + || root === undefined) { + throw new Error('usage: process-exit-host.ts ') +} + +const treeState = join(root, 'tree.json') +const ready = join(root, 'ready') +const proceed = join(root, 'proceed') +const managedTree = fileURLToPath(new URL('./managed-tree.ts', import.meta.url)) + +async function waitForFile(path: string): Promise { + for (;;) { + try { + await access(path) + return + } catch (_notReady) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + } +} + +const listenersBefore = process.listenerCount('exit') +const ctx = new Context() +const fiber = await ctx.plugin(LocalSubprocessService) +const listenersAfterLoad = process.listenerCount('exit') +if (kind === 'ordinary') { + ctx.subprocess.spawn({ + argv: [process.execPath, managedTree, treeState], + cwd: process.cwd(), + stdio: { + stdin: 'ignore', + stdout: { maxBytes: 1024 }, + stderr: { maxBytes: 1024 }, + }, + graceMs: trigger === 'dispose' ? 100 : 30_000, + }) +} else { + await ctx.subprocess.spawnTerminal({ + argv: [process.execPath, managedTree, treeState], + cwd: process.cwd(), + rows: 24, + cols: 80, + graceMs: 30_000, + }) +} + +await waitForFile(treeState) +const published = JSON.parse(await readFile(treeState, 'utf8')) as { root?: unknown; descendant?: unknown } +if (!Number.isSafeInteger(published.root) || !Number.isSafeInteger(published.descendant)) { + throw new Error('managed tree published invalid process ids') +} +await writeFile(ready, 'ready') +await waitForFile(proceed) + +if (trigger === 'dispose') { + await fiber.dispose() + await writeFile(join(root, 'dispose.json'), JSON.stringify({ + listenersBefore, + listenersAfterLoad, + listenersAfterDispose: process.listenerCount('exit'), + })) +} else if (trigger === 'direct') { + process.exit(23) +} else if (trigger === 'uncaught-exception') { + setImmediate(() => { throw new Error('host-exit-uncaught-exception') }) + await new Promise(() => {}) +} else { + void Promise.reject(new Error('host-exit-unhandled-rejection')) + await new Promise(() => {}) +} diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index e3131543f4..22affc50e0 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -21,6 +21,77 @@ function spec(command: string, overrides: Partial = {}): Su } describe('LocalSubprocessService', () => { + it('keeps the host-exit finalizer active until normal disposal reaches quiescence', async () => { + const before = new Set(process.listeners('exit')) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const listener = process.listeners('exit').find(candidate => !before.has(candidate)) + expect(listener).toBeTypeOf('function') + + let finishExit!: () => void + const exited = new Promise((resolve) => { finishExit = resolve }) + const terminate = vi.fn() + const terminateForHostExit = vi.fn() + const live = (ctx.subprocess as unknown as { + live: Set<{ + done: Promise<{ exitCode: number; signal: null }> + terminate(): void + terminateForHostExit(): void + waitForExit(): Promise + }> + }).live + live.add({ + done: Promise.resolve({ exitCode: 0, signal: null }), + terminate, + terminateForHostExit, + waitForExit: async () => { await exited; return true }, + }) + + let disposed = false + const disposing = fiber.dispose().then(() => { disposed = true }) + await new Promise(resolve => setImmediate(resolve)) + expect(disposed).toBe(false) + expect(live.size).toBe(1) + listener?.(0) + expect(terminate).toHaveBeenCalledOnce() + expect(terminateForHostExit).toHaveBeenCalledOnce() + + finishExit() + await disposing + expect(live.size).toBe(0) + expect(process.listeners('exit')).not.toContain(listener) + }) + + it('contains each host-exit termination failure and continues with the other targets', async () => { + const before = new Set(process.listeners('exit')) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const listener = process.listeners('exit').find(candidate => !before.has(candidate)) + expect(listener).toBeTypeOf('function') + const ordinaryFailure = vi.fn(() => { throw new Error('ordinary failed') }) + const ordinarySuccess = vi.fn() + const terminalFailure = vi.fn(() => { throw new Error('terminal failed') }) + const terminalSuccess = vi.fn() + const service = ctx.subprocess as unknown as { + live: Set<{ terminateForHostExit(): void }> + terminals: Set<{ terminateForHostExit(): void }> + } + service.live.add({ terminateForHostExit: ordinaryFailure }) + service.live.add({ terminateForHostExit: ordinarySuccess }) + service.terminals.add({ terminateForHostExit: terminalFailure }) + service.terminals.add({ terminateForHostExit: terminalSuccess }) + + expect(() => { listener?.(0) }).not.toThrow() + expect(ordinaryFailure).toHaveBeenCalledOnce() + expect(ordinarySuccess).toHaveBeenCalledOnce() + expect(terminalFailure).toHaveBeenCalledOnce() + expect(terminalSuccess).toHaveBeenCalledOnce() + + service.live.clear() + service.terminals.clear() + await fiber.dispose() + }) + it('resolves absolute and PATH executables and honors lookup cancellation', async () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessService) @@ -177,6 +248,30 @@ describe('LocalSubprocessService', () => { expect(disposalErrors).toEqual([failure]) }) + it('force-terminates remaining targets before releasing a failed disposal', async () => { + const before = new Set(process.listeners('exit')) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const listener = process.listeners('exit').find(candidate => !before.has(candidate)) + expect(listener).toBeTypeOf('function') + const failure = new Error('cleanup failed') + const terminateForHostExit = vi.fn(() => { + expect(process.listeners('exit')).toContain(listener) + }) + const terminal = { + terminate: vi.fn(async () => { throw failure }), + terminateForHostExit, + } + const terminals = (ctx.subprocess as unknown as { terminals: Set }).terminals + terminals.add(terminal) + + await fiber.dispose() + + expect(terminateForHostExit).toHaveBeenCalledOnce() + expect(terminals.size).toBe(0) + expect(process.listeners('exit')).not.toContain(listener) + }) + it('releases a terminal after top-level exit reaches quiescence', async () => { let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined const inspector = { diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts new file mode 100644 index 0000000000..940d21e838 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -0,0 +1,169 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { execa } from 'execa' +import { describe, expect, it, vi } from 'vitest' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { createProcessInspector } from '../src/process-inspector.ts' +import type { ProcessIdentity, ProcessInspector } from '../src/process-inspector.ts' +import { taskkillProcessTree } from '../src/spawn.ts' + +type ExitTrigger = 'direct' | 'uncaught-exception' | 'unhandled-rejection' | 'dispose' +type ManagedKind = 'ordinary' | 'terminal' +interface TreeState { root: number; descendant: number } + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const hostScript = fileURLToPath(new URL('./fixtures/process-exit-host.ts', import.meta.url)) +const scenarioTimeoutMs = 30_000 + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false + throw error + } +} + +async function readTree(path: string): Promise { + return vi.waitFor(async () => { + const text = await readFile(path, 'utf8') + const state = JSON.parse(text) as Partial + if (!Number.isSafeInteger(state.root) || !Number.isSafeInteger(state.descendant) + || (state.root ?? 0) <= 0 || (state.descendant ?? 0) <= 0 || state.root === state.descendant) { + throw new Error(`invalid managed-tree state: ${text}`) + } + return state as TreeState + }, { interval: 10, timeout: scenarioTimeoutMs }) +} + +async function captureIdentities(inspector: ProcessInspector, state: TreeState): Promise { + return vi.waitFor(() => { + const expected = new Set([state.root, state.descendant]) + const identities = inspector.processTree(state.root).filter(identity => expected.has(identity.pid)) + if (identities.length !== expected.size) throw new Error('managed tree is not fully observable yet') + return identities + }, { interval: 10, timeout: scenarioTimeoutMs }) +} + +async function waitForGone(state: TreeState): Promise { + await Promise.all([state.root, state.descendant].map(pid => vi.waitFor(() => { + if (processExists(pid)) throw new Error(`managed pid ${pid} is still alive`) + }, { interval: 25, timeout: 10_000 }))) +} + +function cleanupTree(state: TreeState | undefined, identities: ProcessIdentity[]): void { + if (state === undefined) return + if (process.platform === 'win32') { + taskkillProcessTree(state.root) + for (const pid of [state.descendant, state.root]) { + try { + process.kill(pid, 'SIGKILL') + } catch (_alreadyGone) { + // The exact recorded process already exited. + } + } + return + } + const inspector = createProcessInspector() + for (const identity of identities) { + try { + inspector.signalProcess(identity, 'SIGKILL') + } catch (_alreadyGone) { + // Exact start identity prevents PID-reuse cleanup from reaching another process. + } + } + if (identities.length === 0) { + for (const pid of [state.descendant, state.root]) { + try { + process.kill(pid, 'SIGKILL') + } catch (_alreadyGone) { + // The scenario failed before process identities became observable. + } + } + } +} + +async function runScenario(kind: ManagedKind, trigger: ExitTrigger) { + const root = await mkdtemp(join(tmpdir(), `dsh-subprocess-host-exit-${kind}-${trigger}-`)) + const launch = resolveExampleLaunch({ + srcBin: hostScript, + mode: 'src', + tsconfigPath: join(repoRoot, 'tsconfig.json'), + configArgs: [kind, trigger, root], + }) + const child = execa(launch.command, launch.args, { + cwd: repoRoot, + env: launch.env, + stdin: 'ignore', + reject: false, + timeout: scenarioTimeoutMs, + }) + let state: TreeState | undefined + let identities: ProcessIdentity[] = [] + let settled = false + try { + state = await readTree(join(root, 'tree.json')) + await vi.waitFor(() => readFile(join(root, 'ready'), 'utf8'), { + interval: 10, + timeout: scenarioTimeoutMs, + }) + if (process.platform !== 'win32') identities = await captureIdentities(createProcessInspector(), state) + await writeFile(join(root, 'proceed'), 'proceed') + const outcome = await child + settled = true + await waitForGone(state) + const disposeCounts = trigger === 'dispose' + ? JSON.parse(await readFile(join(root, 'dispose.json'), 'utf8')) as { + listenersBefore: number + listenersAfterLoad: number + listenersAfterDispose: number + } + : undefined + return { outcome, disposeCounts } + } finally { + if (!settled) { + child.kill('SIGKILL') + await child.catch(() => {}) + } + cleanupTree(state, identities) + if (state !== undefined) await waitForGone(state).catch(() => {}) + await rm(root, { recursive: true, force: true }) + } +} + +describe('synchronous cleanup on host exit', () => { + it.each([ + { trigger: 'direct' as const, expectedCode: 23, diagnostic: undefined }, + { trigger: 'uncaught-exception' as const, expectedCode: 1, diagnostic: 'host-exit-uncaught-exception' }, + { trigger: 'unhandled-rejection' as const, expectedCode: 1, diagnostic: 'host-exit-unhandled-rejection' }, + ])('removes an ordinary managed tree after $trigger', { timeout: 45_000 }, async ({ + trigger, + expectedCode, + diagnostic, + }) => { + const { outcome } = await runScenario('ordinary', trigger) + expect(outcome.exitCode).toBe(expectedCode) + expect(outcome.signal).toBeUndefined() + if (diagnostic !== undefined) expect(outcome.stderr).toContain(diagnostic) + }) + + it.skipIf(process.platform === 'win32')( + 'removes a terminal root and descendant after direct exit', + { timeout: 45_000 }, + async () => { + const { outcome } = await runScenario('terminal', 'direct') + expect(outcome.exitCode).toBe(23) + expect(outcome.signal).toBeUndefined() + }, + ) + + it('preserves normal terminate-and-join disposal and removes the exit listener', { timeout: 45_000 }, async () => { + const { outcome, disposeCounts } = await runScenario('ordinary', 'dispose') + expect(outcome.exitCode).toBe(0) + expect(disposeCounts?.listenersAfterLoad).toBe((disposeCounts?.listenersBefore ?? 0) + 1) + expect(disposeCounts?.listenersAfterDispose).toBe(disposeCounts?.listenersBefore) + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 4cffde6432..3d16376904 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -582,6 +582,25 @@ describe('stdio dispositions', () => { }) describe('windows tree semantics (injected platform)', () => { + it('host-exit termination routes through taskkill immediately', async () => { + const killed: number[] = [] + const running = spawnSubprocess(spec('sleep 60', { graceMs: 60_000 }), { + spillDir, + platform: 'win32', + taskkill: (pid) => { + killed.push(pid) + try { + process.kill(pid, 'SIGKILL') + } catch { + // Already gone — matches taskkill's tolerated not-found status. + } + }, + }) + running.terminateForHostExit() + await running.done + expect(killed).toEqual([running.pid]) + }) + it('terminate routes through taskkill by root pid', async () => { const killed: number[] = [] const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), { @@ -631,6 +650,23 @@ describe('waitForExit', () => { }) }) +describe('synchronous host-exit termination', () => { + it('force-kills the current process tree without waiting for the normal grace', async () => { + const running = spawnSubprocess(spec('trap "" TERM; sleep 60', { graceMs: 60_000 })) + running.terminateForHostExit() + await expect(running.done).resolves.toMatchObject({ exitCode: null, signal: 'SIGKILL' }) + await expect(running.waitForExit()).resolves.toBe(true) + + const kill = vi.spyOn(process, 'kill') + try { + running.terminateForHostExit() + expect(kill).not.toHaveBeenCalled() + } finally { + kill.mockRestore() + } + }) +}) + 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 diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 79501c7dc4..4bfd9f1025 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -74,6 +74,7 @@ class FakeInspector implements ProcessInspector { } signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') { if (this.throwProcess) throw new Error('process raced') + if (!this.isAlive(identity)) return this.processes.push([identity.pid, signal]) if (this.removeOnSignal) this.alive.delete(identity.pid) } @@ -82,6 +83,85 @@ class FakeInspector implements ProcessInspector { afterEach(() => { vi.useRealTimers() }) describe('LocalTerminalHandle', () => { + it('force-kills descendants around the shell during synchronous host exit', () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const first = { pid: 124, started: 'first' } + const late = { pid: 125, started: 'late' } + inspector.members = [first] + inspector.alive.add(pty.pid) + inspector.alive.add(first.pid) + const signalProcess = inspector.signalProcess.bind(inspector) + inspector.signalProcess = (identity, signal) => { + signalProcess(identity, signal) + if (identity.pid === pty.pid) { + inspector.members = [first, late] + inspector.alive.add(late.pid) + } + } + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + + handle.terminateForHostExit() + expect(inspector.processes).toEqual([ + [first.pid, 'SIGKILL'], + [pty.pid, 'SIGKILL'], + [late.pid, 'SIGKILL'], + ]) + expect(pty.kills).toEqual([]) + + pty.emitExit() + handle.terminateForHostExit() + expect(pty.kills).toEqual([]) + }) + + it('uses captured identities and contains shell races when final inspection fails', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const captured = { pid: 124, started: 'captured' } + inspector.members = [captured] + inspector.alive.add(pty.pid) + inspector.alive.add(captured.pid) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + await handle.inspectForeground() + inspector.processTree = () => { throw new Error('process table unavailable') } + inspector.throwProcess = true + + expect(() => { handle.terminateForHostExit() }).not.toThrow() + expect(inspector.processes).toEqual([]) + expect(pty.kills).toEqual([]) + }) + + it('uses node-pty only when the shell start identity was unavailable', () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.root = undefined + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + + handle.terminateForHostExit() + expect(pty.kills).toEqual(['SIGKILL']) + + const racingPty = new FakePty() + const racingInspector = new FakeInspector() + racingInspector.root = undefined + racingPty.throwKill = true + const racingHandle = new LocalTerminalHandle(racingPty.asPty(), racingInspector, 10) + expect(() => { racingHandle.terminateForHostExit() }).not.toThrow() + }) + + it('does not signal a recycled terminal root before its delayed exit callback', () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(pty.pid) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + inspector.root = { pid: pty.pid, started: 'recycled' } + inspector.isAlive = identity => identity.started === 'recycled' + + handle.terminateForHostExit() + + expect(inspector.processes).toEqual([]) + expect(pty.kills).toEqual([]) + }) + it('bridges terminal bytes, foreground control, and signalled exit facts', async () => { const pty = new FakePty() const inspector = new FakeInspector() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5612f5bc13..9ebc69c284 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6992,6 +6992,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../subprocess diff --git a/vitest.config.ts b/vitest.config.ts index c0eb076b5c..44ce8ce176 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,7 +29,6 @@ const windowsUnsupportedPackages = process.platform === 'win32' 'packages/bash/bash-sandbox', 'packages/bash/tool-bash', 'packages/hooks/*', - 'packages/subprocess/*', 'packages/pty/pty-local', 'packages/sandbox/sandbox-local', 'packages/scaffold/create-sdk', @@ -37,6 +36,21 @@ const windowsUnsupportedPackages = process.platform === 'win32' ] : [] +const windowsUnsupportedTests = process.platform === 'win32' + ? [ + ...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + 'packages/subprocess/subprocess/tests/**/*.spec.ts', + 'packages/subprocess/subprocess-local/tests/local.spec.ts', + 'packages/subprocess/subprocess-local/tests/process-inspector.spec.ts', + 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', + 'packages/subprocess/subprocess-local/tests/terminal.spec.ts', + ] + : [] + +const windowsUnsupportedCoveragePackages = process.platform === 'win32' + ? [...windowsUnsupportedPackages, 'packages/subprocess/*'] + : [] + // Windows-only packages: their sources execute exclusively on win32 (koffi // loads Win32 libraries), so the Linux coverage lane can never cover them. // The Windows dev/CI lane exercises them through the probe/runner suites; the @@ -94,6 +108,7 @@ const coverageExemptExcludes = coverageExemptRaw === '1' const processBoundTests = [ 'packages/session/session-persistence-jsonl/tests/jsonl.spec.ts', 'packages/subagent/subagent-acp/tests/subagent-acp.spec.ts', + 'packages/subprocess/subprocess-local/tests/process-exit.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', @@ -107,7 +122,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). include: testIncludes, - exclude: windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + exclude: windowsUnsupportedTests, // One coverage invocation aggregates both projects. Every suite forks for // Node stability; process-bound suites stay separate for inventory control. projects: [ @@ -123,7 +138,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], include: testIncludes, exclude: [ - ...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + ...windowsUnsupportedTests, ...processBoundTests, ...coverageExemptExcludes, ], @@ -138,7 +153,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], include: processBoundTests, exclude: [ - ...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + ...windowsUnsupportedTests, ...coverageExemptExcludes, ], }, @@ -241,7 +256,7 @@ export default defineConfig({ 'packages/interaction/commands/src/index.ts', 'packages/interaction/commands/src/invariant.ts', 'packages/session/session-projection/src/index.ts', - ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), + ...windowsUnsupportedCoveragePackages.map(path => `${path}/src/**/*.ts`), ...windowsOnlyCoverageExclusions, ...windowsRunnerCoverageExclusions, ...pwshCoverageExclusions, From f32a51b2847eb0703b770c80c462d745afde5363 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 11 Aug 2026 16:23:05 +0800 Subject: [PATCH 10/61] fix(tasks): bound per-session background work --- ...eneric-long-running-tool-runtime.i18n.yaml | 4 +- ...06-20-generic-long-running-tool-runtime.md | 8 +- ...20-generic-long-running-tool-runtime.zh.md | 8 +- .../2026-07-26-task-registry-seam.i18n.yaml | 4 +- .../2026-07-26-task-registry-seam.md | 6 +- .../2026-07-26-task-registry-seam.zh.md | 6 +- ...ounded-background-task-admission.i18n.yaml | 6 + ...08-11-bounded-background-task-admission.md | 53 +++++++++ ...11-bounded-background-task-admission.zh.md | 53 +++++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 28 ++++- docs/config-catalog.zh.md | 28 ++++- docs/subsystems/tasks.i18n.yaml | 4 +- docs/subsystems/tasks.md | 11 +- docs/subsystems/tasks.zh.md | 11 +- ...kground-task-admission.cordis.snapshot.yml | 36 ++++++ .../background-task-admission.cordis.yml | 24 ++++ examples/acp-agent/tests/acp.snapshot.ts | 11 ++ .../background-task-admission/input.json | 10 ++ .../replay.override.json | 52 +++++++++ .../background-task-admission/session.jsonl | 58 ++++++++++ .../stdout.expected.jsonl | 4 + packages/examples/acp-demo/README.i18n.yaml | 4 +- packages/examples/acp-demo/README.md | 1 + packages/examples/acp-demo/README.zh.md | 1 + packages/examples/acp-demo/src/index.ts | 3 + .../examples/acp-demo/tests/acp-agent.spec.ts | 13 +++ .../agent-spine-demo/README.i18n.yaml | 4 +- packages/examples/agent-spine-demo/README.md | 4 +- .../examples/agent-spine-demo/README.zh.md | 4 +- .../examples/agent-spine-demo/src/index.ts | 20 +++- .../agent-spine-demo/tests/agent-core.spec.ts | 13 +++ .../tool-cordis/src/api-catalog.ts | 2 +- packages/tasks/tasks-local/README.i18n.yaml | 4 +- packages/tasks/tasks-local/README.md | 8 +- packages/tasks/tasks-local/README.zh.md | 8 +- packages/tasks/tasks-local/package.json | 5 + packages/tasks/tasks-local/src/index.ts | 47 +++++++- .../tests/loader-composition.spec.ts | 52 +++++++++ .../tasks/tasks-local/tests/tasks.spec.ts | 105 +++++++++++++++++- packages/tasks/tasks/README.i18n.yaml | 4 +- packages/tasks/tasks/README.md | 2 +- packages/tasks/tasks/README.zh.md | 2 +- packages/tasks/tasks/src/index.ts | 9 +- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 1 + pnpm-lock.yaml | 10 ++ 46 files changed, 684 insertions(+), 71 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.zh.md create mode 100644 examples/acp-agent/background-task-admission.cordis.snapshot.yml create mode 100644 examples/acp-agent/background-task-admission.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/background-task-admission/input.json create mode 100644 examples/acp-agent/tests/snapshots/background-task-admission/replay.override.json create mode 100644 examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/background-task-admission/stdout.expected.jsonl create mode 100644 packages/tasks/tasks-local/tests/loader-composition.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index 6da549618b..d4b69737f4 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.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/architecture/2026-06-20-generic-long-running-tool-runtime.md -2026-06-20-generic-long-running-tool-runtime.md: 0dd49fe60f2c45081973657ff960b780d2d47257 -2026-06-20-generic-long-running-tool-runtime.zh.md: 6e8458aea73536859bf4c81894679d31a5034d3a +2026-06-20-generic-long-running-tool-runtime.md: a7327af87a7e24351923b07eac54e59afbaff614 +2026-06-20-generic-long-running-tool-runtime.zh.md: 86cc5910fda313ce527e76830e301fd4e26daa38 diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index 0dd49fe60f..a7327af87a 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -25,6 +25,8 @@ Long-running tools are producers. `dsh-tool-bash` adapts a `BashProcess` into in The literal types live on the [tasks subsystem page](../../../../docs/subsystems/tasks.md). A producer calls `ctx.tasks.start()` with a kind, label, optional owning `Agent`, optional positive `outputLimitBytes`, and a `run()` function. The runtime completes all failable preflight work before calling `run()` and invokes it once. After `run()` returns hooks, registration commits without another failable step; a producer cannot start work that lacks a collectable task id. +The process-local provider also owns bounded admission. Its positive-safe-integer `maxConcurrentTasksPerOwner` config defaults to `10`; `start()` derives each exact `Agent` object's active count from `running` and `stopping` records, while every unowned task shares one service bucket. Capacity rejection occurs before `run()` and id allocation, and producer `done` settlement is the only event that releases a stopping task's place. The provider does not queue, preempt, or retain a second mutable count. + `outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `TaskSnapshot`; generic control surfaces apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing surface behavior, so the runtime does not impose a hidden default on unrelated producer families. A model-facing producer exposes that committed id in its canonical success value, normally `{ kind: 'background', taskId }`; Native rendering may keep human-readable prose. A pre-aborted background call fails rather than returning a no-op because no task exists to satisfy the promised handle. Once registration publishes the id, cancellation belongs to the task's own controller and the task runtime: later cancellation of the producing tool call must not kill the published task. `task_kill`, owner disposal, and service teardown request cancellation; foreground execution remains coupled to the call's `exec.signal`. @@ -55,7 +57,7 @@ For contract-compliant producers, `AgentHandle.dispose()` resolves only after ow `TaskService` provides: -- `start(spec)` for preflighted, atomic registration. +- `start(spec)` for preflighted, provider-admitted, atomic registration. - `get(id, caller?)` and `list(caller?)` for non-consuming snapshots. - `read(id, caller?)` for a consuming stream delta or an idempotent final result. - `kill(id, caller?, reason?)` for cancellation. @@ -125,10 +127,12 @@ Authorization, not unguessability, is the access boundary, and ids do not derive ## Testing -Unit coverage pins preflight atomicity, per-kind ids, output-limit validation and projection, complete UTF-8 result bounds, stream and final reads, wait timeout and abort races, cancellation, first-wins settlement, listener containment, notice suppression, owner isolation, stale owner instances, owner cleanup, service teardown, and the no-surface fence. Producer tests cover bash process mapping, subagent startup cancellation, terminal mapping, and disposal. Snapshot coverage pins the control-tool schemas and prompt guidance. +Unit coverage pins preflight atomicity, per-kind ids, per-exact-owner and unowned-bucket admission, `stopping` occupancy, terminal release, output-limit validation and projection, complete UTF-8 result bounds, stream and final reads, wait timeout and abort races, cancellation, first-wins settlement, listener containment, notice suppression, owner isolation, stale owner instances, owner cleanup, service teardown, and the no-surface fence. Producer tests cover bash process mapping, subagent startup cancellation, terminal mapping, and disposal. Snapshot coverage pins the control-tool schemas, prompt guidance, and an assembled ACP path where the configured limit rejects a second real background Bash task with a `task_kill` recovery action. ## Consequences Bash commands and subagents share one id vocabulary, listing, notice format, prompt habit, and set of control tools. New long-running producers implement execution hooks instead of another registry and tool family. The [tool cookbook](../../../../docs/cookbook/adding-a-tool.md) points producers to this contract. +One exact owner cannot grow process-local Task-backed work without bound, and another owner does not consume its allowance. A cancellation request keeps capacity occupied until the producer actually releases its resource, so replacing slow-stopping work cannot exceed the configured live-resource budget. + Owned background bash now stops with its agent instead of surviving it. Background processes have no executor timeout; callers must kill irrelevant work or rely on owner/service disposal. Stream reads support one consuming reader, completion notices do not wake idle agents, and a producer that returns from `cancel` without settling `done` can still stall teardown. Durable jobs, independent observation cursors, and foreground promotion remain separate designs. diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index 6e8458aea7..86cc5910fd 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -25,6 +25,8 @@ Status: implemented 字面类型见[任务子系统页面](../../../../docs/subsystems/tasks.md)。生产方调用 `ctx.tasks.start()`,传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 task id 的工作。 +进程内 Service provider 还拥有有界准入。它的 `maxConcurrentTasksPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service provider 不排队或抢占任务,也不保留第二份可变计数。 + `outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `TaskSnapshot`;通用控制接口添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有接口行为,因此运行时不会向无关的生产方类别施加隐式默认值。 面向模型的生产方会在规范成功值中暴露已提交的 id,通常为 `{ kind: 'background', taskId }`;Native 渲染仍可保留便于人类阅读的行文。预先被中止的后台调用会失败,而不是返回空操作,因为不存在可履行所承诺句柄的任务。一旦注册过程发布 id,取消就归任务自身的控制器与任务运行时所有:随后取消生产工具调用不得终止已发布的任务。`task_kill`、所有者资源释放和服务拆除会请求取消;前台执行仍与调用的 `exec.signal` 耦合。 @@ -55,7 +57,7 @@ task id 在运行时全局可见且可预测,因此注册表会授权每次访 `TaskService` 提供: -- `start(spec)`:经过预检的原子注册。 +- `start(spec)`:经过预检与 Service provider 准入的原子注册。 - `get(id, caller?)` 和 `list(caller?)`:非消费式快照。 - `read(id, caller?)`:消费式流增量或幂等的最终结果。 - `kill(id, caller?, reason?)`:取消。 @@ -125,10 +127,12 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `Bas ## 测试 -单元覆盖固定预检原子性、按 kind 分配的 id、输出上限的校验与投影、完整结果的 UTF-8 字节上限、流式与最终读取、等待超时与中止竞态、取消、首次结果优先的结算、监听器隔离、通知压制、所有者隔离、陈旧的所有者实例、所有者清理、服务资源销毁和无接口防线。生产方测试覆盖 bash 进程映射、subagent 启动取消、终止映射与释放。快照覆盖固定控制工具 schema 与提示词指导。 +单元覆盖固定预检原子性、按 kind 分配的 id、按确切 owner 与无 owner 桶执行的准入、`stopping` 占位、终态释放、输出上限的校验与投影、完整结果的 UTF-8 字节上限、流式与最终读取、等待超时与中止竞态、取消、首次结果优先的结算、监听器隔离、通知压制、所有者隔离、陈旧的所有者实例、所有者清理、服务资源销毁和无接口防线。生产方测试覆盖 bash 进程映射、subagent 启动取消、终止映射与释放。快照覆盖固定控制工具 schema、提示词指导,以及一条组合完整的 ACP 路径:配置上限会拒绝第二个真实后台 Bash 任务,并给出 `task_kill` 恢复动作。 ## 后果 bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)将生产方指向本约定。 +单个确切 owner 无法再无限增加进程内由 Task 承载的工作,另一个 owner 也不会消耗它的额度。取消请求会继续占用容量,直到生产方真正释放资源,因此用新工作替换缓慢停止的任务不会突破已配置的实时资源预算。 + 有所属后台 bash 会随其 agent 一起停止,不再比 agent 存活更久。后台进程没有执行器超时;调用方必须终止无关工作,或依赖所有者/服务释放。流式读取只支持一个消费方,完成通知不会唤醒空闲 agent;生产方的 `cancel` 返回后如果未使 `done` 完成,仍可能阻塞资源销毁。持久任务、独立观察游标和前台提升仍属于单独设计。 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 5236b342a2..550e3e2469 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 .agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md -2026-07-26-task-registry-seam.md: 4487bd9c53595fa8b4eed588b294ceafe3ab58dc -2026-07-26-task-registry-seam.zh.md: 6195dc809e84852c7e0f63ac101ba0ed6a46853e +2026-07-26-task-registry-seam.md: 5a0bd1cab7cdf85968ac53a3689011c8c8bd1b53 +2026-07-26-task-registry-seam.zh.md: 039ead1d577fdeaacf1a52fd8cf9611cc84d430e 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 4487bd9c53..5a0bd1cab7 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 @@ -12,8 +12,8 @@ The [background-task runtime](2026-06-20-generic-long-running-tool-runtime.md) s `tasks/` is now a three-package capability family in the bash-trio shape: -- **`@deepseek-ai/dsh-tasks` (Service Definition)** — the abstract `TaskService extends Service` owning `ctx.tasks`, the eight-method contract (`start`, `list`, `get`, `read`, `kill`, `wait`, `onTaskDone`, `attachSurface`), all vocabulary types (`TaskId`, `TaskKindMap`, `TaskStart`, `TaskHooks`, `TaskOutcome`, `TaskSnapshot`, `TaskRead`, `TaskDoneListener`), and the snapshot invariant companion. The class-level JSDoc states the semantics every Service provider owes: registrations outlive producer and surface fibers, owned access is session-fenced, settlement is first-wins with contained listeners, and `start` refuses work while no attached control surface serves the spec's owner (surfaces and listeners are scope-layered, so one process-wide registry answers both questions per owner). -- **`@deepseek-ai/dsh-tasks-local` (Service provider)** — `LocalTaskService`, the process-local registry moved verbatim: the in-memory store, per-kind counters, waiter bookkeeping, `TASK_WAIT_TIMEOUT` deadline code, owner-cleanup effects, and force-fail teardown. The `dsh-timeout` dependency moves here with it; the Service Definition package has no provider dependencies. +- **`@deepseek-ai/dsh-tasks` (Service Definition)** — the abstract `TaskService extends Service` owning `ctx.tasks`, the nine-method contract (`start`, `list`, `get`, `read`, `kill`, `wait`, `onTaskDone`, `onTasksChanged`, `attachSurface`), all vocabulary types (`TaskId`, `TaskKindMap`, `TaskStart`, `TaskHooks`, `TaskOutcome`, `TaskSnapshot`, `TaskRead`, `TaskDoneListener`), and the snapshot invariant companion. The class-level JSDoc states the semantics every Service provider owes: registrations outlive producer and surface fibers, owned access is session-fenced, settlement is first-wins with contained listeners, and `start` refuses work while no attached control surface serves the spec's owner (surfaces and listeners are scope-layered, so one process-wide registry answers both questions per owner). +- **`@deepseek-ai/dsh-tasks-local` (Service provider)** — `LocalTaskService`, the process-local registry: the in-memory store, per-kind id counters, waiter bookkeeping, `TASK_WAIT_TIMEOUT` deadline code, owner-cleanup effects, force-fail teardown, and the default-10 configurable admission policy. Admission derives `running` plus `stopping` capacity from the same records per exact owner, with one unowned bucket; it adds no public count or second state owner. The `dsh-timeout` dependency and Schemastery-owned provider config live here; the Service Definition package has no provider dependencies. - **`@deepseek-ai/dsh-tool-tasks` (Consumer)** — unchanged; it injects `'tasks'` and never imports provider types. Compositions load `dsh-tasks-local` where they previously loaded `dsh-tasks` (the CLI cordis.yml row, `agent-spine-demo`, test harnesses, the tool-catalog generator boot). Producer misconfiguration diagnostics ("background tasks unavailable: load …") name `dsh-tasks` — the Service Definition package that declares the absent `ctx.tasks` service — and the Service Definition package's own surfaces (its README and the direct-mount fence) point at Service providers, so the producer message stays correct when another backend becomes the recommended default. Producers, `TaskKindMap` declaration merges, and the control surface keep importing `@deepseek-ai/dsh-tasks` only. @@ -30,6 +30,6 @@ The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` st ## Consequences -Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling Service provider implementing eight abstract methods, and no producer, control surface, or `TaskKindMap` extender changes when one lands. The Service Definition README states the contract; the provider README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the Service Definition package keeps a stub-subclass test pinning registration under `ctx.tasks` and single-service duplication behavior, plus the probe-based invariant suite. +Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling Service provider implementing nine abstract methods, and no producer, control surface, or `TaskKindMap` extender changes when one lands. The Service Definition README states the contract; the provider README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the Service Definition package 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 Service provider package. `abstract` erases at runtime and this package name used to be the mountable registry, so the Service Definition constructor fails loudly when mounted directly — a stale composition row gets "load a Service provider such as @deepseek-ai/dsh-tasks-local" at load time instead of a half-registered `ctx.tasks` failing far from the misconfiguration. 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 6195dc809e..039ead1d57 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 @@ -12,8 +12,8 @@ Status: implemented `tasks/` 如今是一个 bash 三件套形态的三包能力家族: -- **`@deepseek-ai/dsh-tasks`(Service Definition)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、八个方法的约定(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`attachSurface`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个 Service provider 都必须兑现的语义:注册的存续期长于生产方与控制接口的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且当没有任何已附加的控制接口服务于 spec 的所有者时 `start` 拒绝启动工作(控制接口与监听器按 scope 分层,因此一个进程级注册表能逐所有者地回答这两个问题)。 -- **`@deepseek-ai/dsh-tasks-local`(Service provider)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除。`dsh-timeout` 依赖随之迁入此包;Service Definition 包不含任何提供方依赖。 +- **`@deepseek-ai/dsh-tasks`(Service Definition)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、九个方法的约定(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`onTasksChanged`、`attachSurface`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个 Service provider 都必须兑现的语义:注册的存续期长于生产方与控制接口的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且当没有任何已附加的控制接口服务于 spec 的所有者时 `start` 拒绝启动工作(控制接口与监听器按 scope 分层,因此一个进程级注册表能逐所有者地回答这两个问题)。 +- **`@deepseek-ai/dsh-tasks-local`(Service provider)**——`LocalTaskService`,即进程内注册表:内存存储、按 kind 划分的 id 计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect、强制失败的拆除,以及默认值为 10 且可配置的准入策略。准入从同一组记录中按确切 owner 派生 `running` 加 `stopping` 容量,并为无 owner 任务使用一个共享桶;它不新增公开计数或第二个状态 owner。`dsh-timeout` 依赖与由 Schemastery 管理的 Service provider 配置都位于此包;Service Definition 包不含任何提供方依赖。 - **`@deepseek-ai/dsh-tool-tasks`(Consumer)**——保持不变;它注入 `'tasks'`,从不导入提供方类型。 各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息(「background tasks unavailable: load …」)点名 `dsh-tasks`——即声明缺失的 `ctx.tasks` 服务的 Service Definition 包;Service Definition 包自身的对外呈现(其 README 与直接挂载防线)会指向各 Service provider,因此当另一个后端日后成为推荐默认时,生产方的消息依旧正确。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。 @@ -30,6 +30,6 @@ Status: implemented ## 后果 -换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的同级 Service provider,这样的注册表落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。Service Definition 的 README 陈述约定;生命周期簿记方面的事实归 Service provider 的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;Service Definition 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 +换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现九个抽象方法的同级 Service provider,这样的注册表落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。Service Definition 的 README 陈述约定;生命周期簿记方面的事实归 Service provider 的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;Service Definition 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名 Service provider 包。`abstract` 在运行时会被擦除,而这个包名过去正是可挂载的具体注册表,因此直接挂载 Service Definition 时,其构造函数会明确报错——一条陈旧的组合配置行会在加载时得到「load a Service provider such as @deepseek-ai/dsh-tasks-local」,而不是一个未完整注册的 `ctx.tasks` 在远离错误配置处才失败。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.i18n.yaml new file mode 100644 index 0000000000..89322f6c54 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.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-08-11-bounded-background-task-admission.md +2026-08-11-bounded-background-task-admission.md: 67bf13320a872dc6d50488058beb4f50330fe83b +2026-08-11-bounded-background-task-admission.zh.md: f2f8be77fc2ade531e004cd026c27073242e7700 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md new file mode 100644 index 0000000000..67bf13320a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md @@ -0,0 +1,53 @@ +# Agent Note: Bounded background task admission + +Status: implemented + +English | [中文](2026-08-11-bounded-background-task-admission.zh.md) + +## Problem + +A model can start background Bash, PowerShell, PTY operations, and one-shot subagents in separate tool calls and later turns. The agent loop's `maxParallelToolCalls` limits only calls still executing inside one step; each background producer returns a task id immediately, so repeated starts can grow live processes or child work without bound. + +The process-local task registry already owns the exact task owner and the authoritative lifecycle state, but it retained terminal history beside live records and had no admission policy. Releasing capacity when cancellation was requested would also be incorrect: a `stopping` producer may still own its process, PTY, or child until `TaskHooks.done` settles. + +## Decision + +`LocalTaskService` owns a `maxConcurrentTasksPerOwner` configuration field. It accepts positive safe integers, defaults to `10`, and is available through the provider's Cordis schema, the typed `agent-spine-demo` bundle, and the ACP app configuration. The bundle transports the value; the process-local provider owns its meaning. + +`start()` performs admission after the existing control-surface, task-field, and live-owner checks and before `TaskStart.run()`. It derives the active count from the registry's current records instead of storing another counter: + +| Record | Occupies capacity | Release fact | +|---|---:|---| +| `running` | yes | producer `done` settles | +| `stopping` | yes | producer `done` settles | +| `completed`, `killed`, or `failed` | no | already terminal | + +Owned tasks are bucketed by exact `Agent` object identity, matching owner cleanup. Replacement agents that reuse a session id receive an independent bucket. Tasks without an owner share one service-level bucket, so omitting ownership is not an unlimited bypass. + +When the bucket is full, `start()` throws before producer execution and task-id allocation. The diagnostic includes the current limit and tells the model to use `task_kill`, wait until the task finishes stopping, and retry. Rejection creates no execution resource, queue entry, reservation, or public task record; a later successful start receives the next ordinary per-kind id. + +Owner and service disposal keep their existing order: request cancellation, retain `stopping` occupancy while producers release resources, await settlement, then remove records. The admission policy therefore follows the same lifecycle fact used by reads, notices, and cleanup rather than treating a cancellation request as resource release. + +Continuable background subagents remain outside this budget. They own durable child sessions and live Activations rather than Task records, so limiting them requires a separate result and lifecycle contract. This decision also adds no Task snapshot, session-log, wire, persistence, process-wide CPU or memory budget, queue, priority, preemption, or automatic oldest-task termination. + +## Verification + +The task-provider suite covers the default and explicit limits, producer-before rejection, unchanged id counters, `stopping` occupancy, every terminal release state, exact-owner isolation, same-session replacement objects, the shared unowned bucket, invalid configuration, owner cleanup, and service teardown. Spine and ACP composition tests pin typed forwarding. A keyless ACP replay boots the real Loader composition with a limit of one, starts one real background Bash process, observes the second start's actionable error, stops the first task by its returned id, and verifies that the rejected producer's marker file was never created. + +## Alternatives considered + +**Rely on `maxParallelToolCalls`.** Rejected because a background tool call releases its step slot as soon as it returns a task id; the setting cannot bound work that remains live across later steps and turns. + +**Release capacity when `task_kill` succeeds.** Rejected because successful cancellation only changes the task to `stopping`. The producer may still hold the resource until `done` settles, so admitting a replacement immediately would exceed the configured live-resource bound. + +**Use one global process bucket.** Rejected because one busy agent would deny unrelated sessions, while unowned host work still needs an explicit bounded bucket. Exact owner identity already defines the cleanup lifecycle and supplies the correct partition. + +**Queue, preempt, or terminate the oldest task.** Rejected because each policy adds ordering, ownership, and cancellation behavior beyond the requested fail-closed limit. An explicit rejection lets the model decide which work is no longer needed through the existing `task_kill` control. + +**Maintain a mutable active-count map.** Rejected because the registry already holds the authoritative records and statuses. A second count would require rollback and settlement synchronization while providing no user result that a direct derivation lacks. + +## Consequences + +One exact owner cannot keep creating Task-backed live resources indefinitely, and unrelated owners retain independent allowances. A slow stop can temporarily keep a bucket full, which is deliberate: the configured number bounds work that may still own resources, not cancellation requests. + +Admission scans the process-local registry on each start. The cost grows with retained Task history, accepted in exchange for one state authority and a default limit small enough to bound the common live set. Terminal history remains available to existing reads and listings without consuming capacity. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.zh.md new file mode 100644 index 0000000000..f2f8be77fc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.zh.md @@ -0,0 +1,53 @@ +# Agent Note: 有界后台任务准入 + +Status: implemented + +[English](2026-08-11-bounded-background-task-admission.md) | 中文 + +## 问题 + +模型可以在不同工具调用和后续回合中启动后台 Bash、PowerShell、PTY 操作与一次性 subagent。agent loop 的 `maxParallelToolCalls` 只限制单个步骤中尚未返回的调用;每个后台生产方会立即返回 task id,因此反复启动会让仍存活的进程或子工作无限增长。 + +进程内任务注册表已经拥有确切任务 owner 与权威生命周期状态,但终止历史和实时记录保存在一起,且没有准入策略。在请求取消时立即释放容量也不正确:处于 `stopping` 的生产方仍可能拥有进程、PTY 或子任务,直到 `TaskHooks.done` 结算。 + +## 决策 + +`LocalTaskService` 拥有 `maxConcurrentTasksPerOwner` 配置字段。它只接受正的安全整数,默认值为 `10`,并通过 Service provider 的 Cordis schema、typed `agent-spine-demo` 组合包与 ACP 应用配置提供。组合包只传输该值;其含义归进程内 Service provider 所有。 + +`start()` 在现有控制表层、任务字段与存活 owner 检查之后、`TaskStart.run()` 之前执行准入。它从注册表当前记录派生活动数量,而不保存另一份计数: + +| 记录 | 占用容量 | 释放事实 | +|---|---:|---| +| `running` | 是 | 生产方 `done` 结算 | +| `stopping` | 是 | 生产方 `done` 结算 | +| `completed`、`killed` 或 `failed` | 否 | 已经终止 | + +有 owner 的任务按确切 `Agent` 对象身份分桶,与 owner 清理保持一致。复用同一会话 id 的替代 agent 获得独立桶。无 owner 的任务共享一个服务级桶,因此省略 owner 不会成为无界旁路。 + +桶已满时,`start()` 会在生产方执行和 task id 分配前抛出异常。诊断包含当前上限,并告诉模型使用 `task_kill`、等待任务完全停稳后再重试。拒绝不会创建执行资源、排队项、预留或公开任务记录;后续成功启动仍会取得按 kind 正常递增的下一个 id。 + +owner 与服务释放保留现有顺序:请求取消,在生产方释放资源期间继续让 `stopping` 占位,等待结算,然后移除记录。因此,准入策略遵循读取、通知与清理共同使用的同一生命周期事实,而不会把取消请求误当成资源释放。 + +可继续后台 subagent 仍不纳入此预算。它们拥有持久 child session 与实时 Activation,而不是 Task 记录;限制它们需要独立的用户结果与生命周期约定。本决策也不会新增 Task 快照、会话日志、wire、持久化、进程级 CPU 或内存预算、队列、优先级、抢占或自动终止最旧任务。 + +## 验证 + +任务 Service provider 测试覆盖默认与显式上限、生产方执行前拒绝、id 计数器不变、`stopping` 占位、每种终态释放、确切 owner 隔离、同会话替代对象、共享无 owner 桶、非法配置、owner 清理和服务拆除。spine 与 ACP 组合测试固定 typed 转发。一条 keyless ACP 回放以 1 为上限启动真实 Loader 组合,启动一个真实后台 Bash 进程,观察第二次启动返回可操作错误,按返回的 task id 停止第一个任务,并验证被拒绝生产方的标记文件从未生成。 + +## 曾考虑的替代方案 + +**依赖 `maxParallelToolCalls`。**否决,因为后台工具调用一返回 task id 就会释放其步骤槽位;该设置无法限制在后续步骤和回合中继续存活的工作。 + +**在 `task_kill` 成功时释放容量。**否决,因为取消成功只会把任务改为 `stopping`。生产方在 `done` 结算前仍可能持有资源,立即准入替代任务会突破已配置的实时资源上限。 + +**使用一个全局进程桶。**否决,因为一个繁忙 agent 会拒绝无关会话,而无 owner 的宿主工作仍需要一个明确的有界桶。确切 owner 身份已经定义清理生命周期,并提供正确分区。 + +**排队、抢占或终止最旧任务。**否决,因为每种策略都会增加超出 fail-closed 上限要求的顺序、所有权和取消行为。显式拒绝让模型通过现有 `task_kill` 控制自行决定哪些工作不再需要。 + +**维护一张可变活动计数表。**否决,因为注册表已经保存权威记录与状态。第二份计数需要回滚和结算同步,却无法提供直接派生所缺少的用户结果。 + +## 后果 + +单个确切 owner 无法再无限创建由 Task 承载的实时资源,无关 owner 则保留独立额度。缓慢停止可能暂时让桶保持满载,这是有意行为:配置值限制的是仍可能拥有资源的工作,而不是取消请求。 + +每次启动都会扫描进程内注册表。成本随保留的 Task 历史增长;为了保持单一状态权威,并利用足以约束常见实时集合的较小默认值,接受这一代价。终止历史仍可供现有读取与列表使用,但不消耗容量。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 75e5d33e09..1e515c7699 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 1e2416d479daaef0f9b6b03846406fab4c30f21a -config-catalog.zh.md: 5a9ecc5b30c55f70b2685fe9633430e68f30f6c8 +config-catalog.md: 75cc0f13e17de61cf618e45cc85dab50717bb686 +config-catalog.zh.md: a84967967def721ef7fc4834b31f688a6f70bba1 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1e2416d479..75cc0f13e1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -69,6 +69,8 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable + /** Process-local background-task admission config forwarded through agent-core. */ + tasks?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ @@ -167,9 +169,10 @@ Source: [`packages/preset/agent-presets/src/types.ts:52`](../packages/preset/age * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. Provider adapters own their `retryPolicy`; this - * bundle always mounts its executor. + * workspace-context loader, `tasks` to the process-local task provider, and + * `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * Provider adapters own their `retryPolicy`; this bundle always mounts its + * executor. * `goals` opts into and configures the persisted goal domain plus its model tool * and same-session driver; `invariants` configures global and package-filtered * relational checks. Owner schemas supply defaults for optional input; @@ -206,6 +209,8 @@ export interface Config { skills?: SkillConfig /** Model-facing bash tool config, or false when another plugin owns `bash`. */ toolBash?: toolBash.Config | false + /** Process-local background-task admission config. */ + tasks?: TasksConfig /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ @@ -235,9 +240,9 @@ export interface GoalConfig { } ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) +Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`TasksConfig`](#deepseek-aidsh-tasks-local) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:90`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:91`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-tool-mode` @@ -2023,6 +2028,18 @@ export interface Config { Source: [`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) +## `@deepseek-ai/dsh-tasks-local` + +```ts config-catalog +/** Configuration for the process-local task registry. */ +export interface Config { + /** Maximum `running` plus `stopping` tasks per exact owner; omission defaults to 10. */ + maxConcurrentTasksPerOwner?: number +} +``` + +Source: [`packages/tasks/tasks-local/src/index.ts:31`](../packages/tasks/tasks-local/src/index.ts) + ## `@deepseek-ai/dsh-time-context` Requires: `agents` @@ -2776,7 +2793,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@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/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 5a9ecc5b30..a84967967d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -71,6 +71,8 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable + /** Process-local background-task admission config forwarded through agent-core. */ + tasks?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ @@ -169,9 +171,10 @@ export type PresetTrust = 'system' | 'user' * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. Provider adapters own their `retryPolicy`; this - * bundle always mounts its executor. + * workspace-context loader, `tasks` to the process-local task provider, and + * `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * Provider adapters own their `retryPolicy`; this bundle always mounts its + * executor. * `goals` opts into and configures the persisted goal domain plus its model tool * and same-session driver; `invariants` configures global and package-filtered * relational checks. Owner schemas supply defaults for optional input; @@ -208,6 +211,8 @@ export interface Config { skills?: SkillConfig /** Model-facing bash tool config, or false when another plugin owns `bash`. */ toolBash?: toolBash.Config | false + /** Process-local background-task admission config. */ + tasks?: TasksConfig /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ @@ -237,9 +242,9 @@ export interface GoalConfig { } ``` -依赖:[`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) +依赖:[`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`TasksConfig`](#deepseek-aidsh-tasks-local) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -来源:[`packages/examples/agent-spine-demo/src/index.ts:90`](../packages/examples/agent-spine-demo/src/index.ts) +来源:[`packages/examples/agent-spine-demo/src/index.ts:91`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-tool-mode` @@ -2025,6 +2030,18 @@ export interface Config { 来源:[`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) +## `@deepseek-ai/dsh-tasks-local` + +```ts config-catalog +/** Configuration for the process-local task registry. */ +export interface Config { + /** Maximum `running` plus `stopping` tasks per exact owner; omission defaults to 10. */ + maxConcurrentTasksPerOwner?: number +} +``` + +来源:[`packages/tasks/tasks-local/src/index.ts:31`](../packages/tasks/tasks-local/src/index.ts) + ## `@deepseek-ai/dsh-time-context` 需要:`agents` @@ -2777,7 +2794,6 @@ export interface Config { - `@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` — 需要 `tools`([`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — 需要 `tools` · `userInteraction`([`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — 需要 `tools` · `subagents`([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index 75b47a572b..74a38d9c4d 100644 --- a/docs/subsystems/tasks.i18n.yaml +++ b/docs/subsystems/tasks.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 docs/subsystems/tasks.md -tasks.md: 6d205d9a7840aef1c115c97836886f51bed829b9 -tasks.zh.md: 59b7c03d240c45e633f55583b3e08776ec470d52 +tasks.md: 213f7f9c50297e214a699ca1ae34b388707e5958 +tasks.zh.md: 28f66e523c2f23c43949f41832a356624ea9b3d2 diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index 6d205d9a78..213f7f9c50 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -151,7 +151,7 @@ interface TaskRead { ## Service behavior -The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, failure-isolated `onTaskDone` listeners, and when `attachSurface` becomes available; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local Service provider. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the Service Definition contract, [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md) for the registry lifecycle, and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing Consumer. +The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, failure-isolated `onTaskDone` listeners, and when `attachSurface` becomes available; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local Service provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentTasksPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned tasks; terminal producer settlement releases capacity. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the Service Definition contract, [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing Consumer. @@ -176,10 +176,11 @@ Implementations must honor these semantics: ```ts cordis-catalog /** - * Preflight access, validation, and owner cleanup before starting and - * atomically registering work. A throwing starter leaves nothing registered; - * after it returns, registration cannot fail. Settlement records the outcome, - * notifies listeners, and releases waiters. + * Preflight access, validation, owner cleanup, and implementation-owned + * admission before starting and atomically registering work. Any preflight + * rejection leaves no task id or execution resource. A throwing starter + * leaves nothing registered; after it returns, registration cannot fail. + * Settlement records the outcome, notifies listeners, and releases waiters. * @param spec - task identity, owner, and synchronous starter. * @returns the registry-issued `-N` id. */ diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index 59b7c03d24..28f66e523c 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -151,7 +151,7 @@ interface TaskRead { ## 服务行为 -抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 与 `onTasksChanged` 监听器,以及 `attachSurface` 何时可用;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部 Service provider。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 +抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 与 `onTasksChanged` 监听器,以及 `attachSurface` 何时可用;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部 Service provider。授权会比较拥有者会话;拥有者清理与准入会使用确切的已注册 `Agent` 实例。本地 Service provider 的 `maxConcurrentTasksPerOwner` 配置必须是正的安全整数,默认值为 `10`;它按确切 owner 统计 `running` 与 `stopping` 记录,所有无 owner 任务共享一个服务级桶,并在生产方终止结算后释放容量。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期与准入策略见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 @@ -176,10 +176,11 @@ Implementations must honor these semantics: ```ts cordis-catalog /** - * Preflight access, validation, and owner cleanup before starting and - * atomically registering work. A throwing starter leaves nothing registered; - * after it returns, registration cannot fail. Settlement records the outcome, - * notifies listeners, and releases waiters. + * Preflight access, validation, owner cleanup, and implementation-owned + * admission before starting and atomically registering work. Any preflight + * rejection leaves no task id or execution resource. A throwing starter + * leaves nothing registered; after it returns, registration cannot fail. + * Settlement records the outcome, notifies listeners, and releases waiters. * @param spec - task identity, owner, and synchronous starter. * @returns the registry-issued `-N` id. */ diff --git a/examples/acp-agent/background-task-admission.cordis.snapshot.yml b/examples/acp-agent/background-task-admission.cordis.snapshot.yml new file mode 100644 index 0000000000..ec174f1f51 --- /dev/null +++ b/examples/acp-agent/background-task-admission.cordis.snapshot.yml @@ -0,0 +1,36 @@ +# Keyless counterpart to background-task-admission.cordis.yml: replace the +# DeepSeek adapter with replay while preserving the app's one-task admission +# config and the recorded flash route. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + tasks: + maxConcurrentTasksPerOwner: 1 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/background-task-admission.cordis.yml b/examples/acp-agent/background-task-admission.cordis.yml new file mode 100644 index 0000000000..374e8449ea --- /dev/null +++ b/examples/acp-agent/background-task-admission.cordis.yml @@ -0,0 +1,24 @@ +# Bounded-task admission overlay: keep the ordinary ACP composition while +# configuring its task provider to allow one active task per exact owner. The +# scenario starts a real background Bash process, observes the second producer +# rejection, and cleans up the first task by its returned id. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: + maxBytes: 65536 + tasks: + maxConcurrentTasksPerOwner: 1 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 3ef3e71000..5765674b4f 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -57,6 +57,9 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) +const BACKGROUND_TASK_ADMISSION_CONFIG = fileURLToPath( + new URL('../background-task-admission.cordis.yml', import.meta.url), +) const PRODUCT_SUBAGENT_CODEX_CONFIG = fileURLToPath(new URL('../product-subagent-codex.cordis.yml', import.meta.url)) const PRODUCT_SUBAGENT_BOTH_CONFIG = fileURLToPath(new URL('../product-subagent-both.cordis.yml', import.meta.url)) const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url)) @@ -215,6 +218,14 @@ const SCENARIOS: Scenario[] = [ configPath: PTY_CONFIG, }, { name: 'bash-tool-turn', hasModelTurn: true, recorded: true }, + { + name: 'background-task-admission', + hasModelTurn: true, + recorded: false, + overridden: true, + configPath: BACKGROUND_TASK_ADMISSION_CONFIG, + posixOnly: true, + }, // The pwsh overlay (pwsh.cordis.yml / pwsh.cordis.snapshot.yml) swaps the // bundle's bash tool for the PowerShell twin, so its header class pins its // own prompt/tool sidecars and a recorded transcript. diff --git a/examples/acp-agent/tests/snapshots/background-task-admission/input.json b/examples/acp-agent/tests/snapshots/background-task-admission/input.json new file mode 100644 index 0000000000..38b1ab6dde --- /dev/null +++ b/examples/acp-agent/tests/snapshots/background-task-admission/input.json @@ -0,0 +1,10 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { + "op": "prompt", + "text": "Start one background Bash task that stays alive. Immediately try to start a second background Bash task, observe the limit error, stop the first task by its returned task id, verify that second-task-ran.txt does not exist, then reply with exactly BOUNDED_BACKGROUND_TASKS and stop." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/background-task-admission/replay.override.json b/examples/acp-agent/tests/snapshots/background-task-admission/replay.override.json new file mode 100644 index 0000000000..45c6a4acda --- /dev/null +++ b/examples/acp-agent/tests/snapshots/background-task-admission/replay.override.json @@ -0,0 +1,52 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "bounded-task-first", "name": "bash", "argumentsDelta": "{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "bounded-task-first", "name": "bash", "arguments": "{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "bounded-task-second", "name": "bash", "argumentsDelta": "{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "bounded-task-second", "name": "bash", "arguments": "{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "bounded-task-kill", "name": "task_kill", "argumentsDelta": "{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "bounded-task-kill", "name": "task_kill", "arguments": "{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "bounded-task-side-effect-check", "name": "bash", "argumentsDelta": "{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "bounded-task-side-effect-check", "name": "bash", "arguments": "{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "BOUNDED_BACKGROUND_TASKS" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "BOUNDED_BACKGROUND_TASKS" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl b/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl new file mode 100644 index 0000000000..0a303e1866 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl @@ -0,0 +1,58 @@ +{"type":"session","version":0,"id":"77777777-7777-4777-8777-777777777777","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786434813544,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Start one background Bash task that stays alive. Immediately try to start a second background Bash task, observe the limit error, stop the first task by its returned task id, verify that second-task-ran.txt does not exist, then reply with exactly BOUNDED_BACKGROUND_TASKS and stop."}],"source":{"kind":"user"},"role":"user","id":"fca9abcd-66a9-4c79-ab34-7e25e65e01af"}]}} +{"type":"turn/start","seq":1,"time":1786434813545,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786434813546,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786434813574,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786434813574,"data":{"content":[{"type":"text","text":"Start one background Bash task that stays alive. Immediately try to start a second background Bash task, observe the limit error, stop the first task by its returned task id, verify that second-task-ran.txt does not exist, then reply with exactly BOUNDED_BACKGROUND_TASKS and stop."}],"source":{"kind":"user"},"role":"user","id":"fca9abcd-66a9-4c79-ab34-7e25e65e01af"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786434813575,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"f7801581-b729-4cbc-b205-1eabd5b96de7"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786434813575,"data":{"title":"Start one background Bash task","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786434813576,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786434813576,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1786434813581,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1786434813581,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-first","name":"bash","argumentsDelta":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}"}}} +{"type":"assistant/chunk","seq":11,"time":1786434813581,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-first","name":"bash","arguments":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}"}}}} +{"type":"assistant/chunk","seq":12,"time":1786434813581,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1786434813581,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1786434813582,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-first","name":"bash","arguments":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f25e0e7c-76a4-45a6-a825-64d1bd42fe59"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1786434813582,"data":{"turn":1,"step":1,"callId":"bounded-task-first","name":"bash","arguments":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}"}} +{"type":"tool/result","seq":16,"time":1786434813594,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"bounded-task-first"},"content":[{"type":"tool-result","toolCallId":"bounded-task-first","content":[{"type":"text","text":"started background task bash-1"}],"isError":false}],"role":"user","id":"0e19086f-2a9a-4e78-b5eb-5a117cad9416"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1786434813594,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1786434813600,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":20,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-second","name":"bash","argumentsDelta":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}}} +{"type":"assistant/chunk","seq":21,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}}}} +{"type":"assistant/chunk","seq":22,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":23,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":1786434813605,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"48c909b3-5651-462f-b0d3-09198d119a2f"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":1786434813606,"data":{"turn":1,"step":2,"callId":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}} +{"type":"tool/result","seq":26,"time":1786434813609,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"bounded-task-second"},"content":[{"type":"tool-result","toolCallId":"bounded-task-second","content":[{"type":"text","text":"Error: background task limit reached for this owner (1/1 active); use task_kill to stop an unneeded task, wait for it to finish, then retry"}],"isError":true}],"role":"user","id":"982adb5c-13ef-44c5-a53e-b726c986ac68"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1786434813609,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":28,"time":1786434813614,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":29,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":30,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-kill","name":"task_kill","argumentsDelta":"{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}"}}} +{"type":"assistant/chunk","seq":31,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-kill","name":"task_kill","arguments":"{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}"}}}} +{"type":"assistant/chunk","seq":32,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":33,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":34,"time":1786434813618,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-kill","name":"task_kill","arguments":"{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6dc2d854-59f7-4c70-8a0f-64416b324055"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"tool/call","seq":35,"time":1786434813618,"data":{"turn":1,"step":3,"callId":"bounded-task-kill","name":"task_kill","arguments":"{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}"}} +{"type":"tool/result","seq":36,"time":1786434813623,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"bounded-task-kill"},"content":[{"type":"tool-result","toolCallId":"bounded-task-kill","content":[{"type":"text","text":"requested cancellation of task bash-1"}],"isError":false}],"role":"user","id":"5abf87b2-3e10-448f-a529-dfc40dce2f08"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"step/end","seq":37,"time":1786434813623,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":38,"time":1786434813628,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":39,"time":1786434813632,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":40,"time":1786434813632,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-side-effect-check","name":"bash","argumentsDelta":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}}} +{"type":"assistant/chunk","seq":41,"time":1786434813632,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-side-effect-check","name":"bash","arguments":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}}}} +{"type":"assistant/chunk","seq":42,"time":1786434813632,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":43,"time":1786434813632,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":44,"time":1786434813632,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-side-effect-check","name":"bash","arguments":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"85ebd1ec-c3b2-4bd2-87cb-135089efc440"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"tool/call","seq":45,"time":1786436340879,"data":{"turn":1,"step":4,"callId":"bounded-task-side-effect-check","name":"bash","arguments":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}} +{"type":"tool/result","seq":46,"time":1786436340886,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"bounded-task-side-effect-check"},"content":[{"type":"tool-result","toolCallId":"bounded-task-side-effect-check","content":[{"type":"text","text":"(no output)"}],"isError":false}],"role":"user","id":"49f76b57-92a2-4ae2-9711-11ad7cbd4e4c"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"step/end","seq":47,"time":1786436340886,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":48,"time":1786436340891,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":49,"time":1786436340897,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":50,"time":1786436340897,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"BOUNDED_BACKGROUND_TASKS"}}} +{"type":"assistant/chunk","seq":51,"time":1786436340897,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"BOUNDED_BACKGROUND_TASKS"}}}} +{"type":"assistant/chunk","seq":52,"time":1786436340897,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":53,"time":1786436340897,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":54,"time":1786436340897,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"BOUNDED_BACKGROUND_TASKS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"de775b06-2bb8-4bc0-8716-4fc31b9685c6"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"step/end","seq":55,"time":1786436340898,"data":{"turn":1,"step":5}} +{"type":"turn/end","seq":56,"time":1786436340898,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/background-task-admission/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/background-task-admission/stdout.expected.jsonl new file mode 100644 index 0000000000..7f71f1b79b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/background-task-admission/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"BOUNDED_BACKGROUND_TASKS"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/examples/acp-demo/README.i18n.yaml b/packages/examples/acp-demo/README.i18n.yaml index af16e2eacd..4d32289fc7 100644 --- a/packages/examples/acp-demo/README.i18n.yaml +++ b/packages/examples/acp-demo/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/examples/acp-demo/README.md -README.md: edc45c9857a631cef72eb41b1a98c390f112291e -README.zh.md: c2946aa3d1feaed558408cf0921e2480c031187d +README.md: c1a15a424d9d66b90bbec451e220198bfe0a45df +README.zh.md: 16a8d782c8fa38fe9e2ae1e634dbc406be1cbd12 diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index edc45c9857..c1a15a424d 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -34,6 +34,7 @@ The app does not install commands, user interaction, session navigation, configu | `workspaceContext` | required | Workspace-instruction byte budget/config, or `false`. | | `skills` | owner defaults | Skill registry, local provider, and model-facing skill tool. | | `toolBash` | owner defaults | Model-facing bash tool config. | +| `tasks` | `{ maxConcurrentTasksPerOwner: 10 }` | Process-local per-owner active-task admission. | | `toolTasks` | owner defaults | Generic background-task control config, or `false`. | | `goals` | owner defaults | Persisted same-session goal domain and model tools, or `false`. | diff --git a/packages/examples/acp-demo/README.zh.md b/packages/examples/acp-demo/README.zh.md index c2946aa3d1..16a8d782c8 100644 --- a/packages/examples/acp-demo/README.zh.md +++ b/packages/examples/acp-demo/README.zh.md @@ -34,6 +34,7 @@ ACP(Agent Client Protocol)自动化服务器应用:默认 agent(智能 | `workspaceContext` | 必填 | 工作区指令字节预算/配置,或 `false`。 | | `skills` | 拥有者默认值 | skill 注册表、本地提供方和面向模型的 skill 工具。 | | `toolBash` | 拥有者默认值 | 面向模型的 bash 工具配置。 | +| `tasks` | `{ maxConcurrentTasksPerOwner: 10 }` | 进程内按 owner 限制活动任务的准入配置。 | | `toolTasks` | 拥有者默认值 | 通用后台任务控制配置,或 `false`。 | | `goals` | 拥有者默认值 | 持久化的同会话目标领域与模型工具,或 `false`。 | diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 1bd0c44459..bd1ecca437 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -65,6 +65,8 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable + /** Process-local background-task admission config forwarded through agent-core. */ + tasks?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */ toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ @@ -92,6 +94,7 @@ export const Config: z = z.object({ workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, + tasks: agentCore.TasksConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), goals: z.union([z.const(false), agentCore.GoalConfigSchema]), }) diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index af467660da..65479f0489 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -182,6 +182,19 @@ describe('dsh-acp-demo composition', () => { await ctx.fiber.dispose() }) + it('forwards task admission config to the bundled task provider', async () => { + const ctx = await mount({ + provider: 'mock', + model: 'mock', + tasks: { maxConcurrentTasksPerOwner: 2 }, + skills: await isolatedSkillsConfig(), + workspaceContext: false, + }) + expect((ctx.tasks as unknown as { config: { maxConcurrentTasksPerOwner: number } }) + .config.maxConcurrentTasksPerOwner).toBe(2) + await ctx.fiber.dispose() + }) + it('forwards bundled tool config into agent-core', async () => { const ctx = await mount({ provider: 'mock', diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index 87c1dc1562..9aa87434a4 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/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/examples/agent-spine-demo/README.md -README.md: 5957d9a8e9218e18d5d7d0f620b6be811f2c230f -README.zh.md: 78372240764ff3c779ea0805aedd26a00baf1768 +README.md: 789715e53038f610d1e2db79cf56f9aabd681fac +README.zh.md: 6561dd8948d9a4763123f9aae31e6dde64c54b27 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 5957d9a8e9..789715e530 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -55,11 +55,11 @@ This applies the [Service Definition / Service provider / Consumer separation](. ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, tasks?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. Prompt, tool, title, skill, workspace-context, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. +The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. Prompt, tool, title, skill, workspace-context, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `tasks.maxConcurrentTasksPerOwner` configures the local provider independently of the model-facing `toolTasks` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 7837224076..6561dd8948 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -55,11 +55,11 @@ ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, tasks?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 +组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`tasks.maxConcurrentTasksPerOwner` 配置本地 Service provider,并与面向模型的 `toolTasks` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。 diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 383fc8c212..2b7644e3cc 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -22,7 +22,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-goal' import * as goalSession from '@deepseek-ai/dsh-goal-session' import * as toolGoal from '@deepseek-ai/dsh-tool-goal' -import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import LocalTaskService, { type Config as TasksConfig } from '@deepseek-ai/dsh-tasks-local' import InvariantService, { type Config as InvariantConfig } from '@deepseek-ai/dsh-invariants' import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant' @@ -75,9 +75,10 @@ export interface GoalConfig { * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. Provider adapters own their `retryPolicy`; this - * bundle always mounts its executor. + * workspace-context loader, `tasks` to the process-local task provider, and + * `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * Provider adapters own their `retryPolicy`; this bundle always mounts its + * executor. * `goals` opts into and configures the persisted goal domain plus its model tool * and same-session driver; `invariants` configures global and package-filtered * relational checks. Owner schemas supply defaults for optional input; @@ -114,6 +115,8 @@ export interface Config { skills?: SkillConfig /** Model-facing bash tool config, or false when another plugin owns `bash`. */ toolBash?: toolBash.Config | false + /** Process-local background-task admission config. */ + tasks?: TasksConfig /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ @@ -138,6 +141,9 @@ export const SessionTitleConfigSchema: z = SessionTitleServi export const ToolBashConfigSchema: z = z.union([z.const(false), toolBash.Config]) +/** The process-local task registry schema exported for app packages that forward `tasks`. */ +export const TasksConfigSchema: z = LocalTaskService.Config + /** The task-control-tool config schema exported for app packages that forward `toolTasks`. */ export const ToolTasksConfigSchema: z = toolTasks.Config @@ -158,10 +164,11 @@ export const Config = z.intersect([ skills: SkillConfigSchema, workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), toolBash: ToolBashConfigSchema, + tasks: TasksConfigSchema, toolTasks: z.union([z.const(false), ToolTasksConfigSchema]), invariants: InvariantService.Config, goals: z.union([z.const(false), GoalConfigSchema]), - }) as unknown as z>, + }) as unknown as z>, ]) as unknown as z /** @@ -181,6 +188,7 @@ export function pickSpineConfig(config: Omit): Omit { expect(ctx.get('skills')).toBeDefined() expect(ctx.get('agents')).toBeDefined() expect(ctx.get('tasks')).toBeDefined() + expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(10) expect(ctx.get('invariants')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() expect(ctx.get('goals')).toBeUndefined() @@ -300,6 +302,15 @@ describe('dsh-agent-spine-demo bundle', () => { await ctx.fiber.dispose() }) + it('forwards task admission config to the process-local provider', async () => { + const ctx = await mount({ + tasks: { maxConcurrentTasksPerOwner: 3 }, + workspaceContext: false, + }) + expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(3) + await ctx.fiber.dispose() + }) + it('tolerates a schema-bypassing direct apply (the ?? fallbacks fire)', async () => { // ctx.plugin validates + defaults the bundle config first; a direct apply // skips the schema, so the forwarding `?? []` / `?? ''` are what fire. @@ -716,6 +727,7 @@ describe('dsh-agent-spine-demo bundle', () => { workspaceContext: false as const, skills: { enabled: false }, toolBash: { enableRunInBackground: false }, + tasks: { maxConcurrentTasksPerOwner: 4 }, toolTasks: false as const, invariants: { enabled: false }, } @@ -730,6 +742,7 @@ describe('dsh-agent-spine-demo bundle', () => { workspaceContext: false, skills: appConfig.skills, toolBash: appConfig.toolBash, + tasks: appConfig.tasks, toolTasks: appConfig.toolTasks, invariants: appConfig.invariants, }) diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 1c310ab8e0..79585369c0 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1150,7 +1150,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'abstract start(spec: TaskStart): TaskId', - jsDoc: '/**\n * Preflight access, validation, and owner cleanup before starting and\n * atomically registering work. A throwing starter leaves nothing registered;\n * after it returns, registration cannot fail. Settlement records the outcome,\n * notifies listeners, and releases waiters.\n * @param spec - task identity, owner, and synchronous starter.\n * @returns the registry-issued `-N` id.\n */', + jsDoc: '/**\n * Preflight access, validation, owner cleanup, and implementation-owned\n * admission before starting and atomically registering work. Any preflight\n * rejection leaves no task id or execution resource. A throwing starter\n * leaves nothing registered; after it returns, registration cannot fail.\n * Settlement records the outcome, notifies listeners, and releases waiters.\n * @param spec - task identity, owner, and synchronous starter.\n * @returns the registry-issued `-N` id.\n */', }, { signature: 'abstract list(caller?: Agent): TaskSnapshot[]', diff --git a/packages/tasks/tasks-local/README.i18n.yaml b/packages/tasks/tasks-local/README.i18n.yaml index 3a43cba8f8..7378b1cd05 100644 --- a/packages/tasks/tasks-local/README.i18n.yaml +++ b/packages/tasks/tasks-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 packages/tasks/tasks-local/README.md -README.md: 80d7932466188955ade1c14e4968d51b739ba818 -README.zh.md: 5e4263e4685be64f91ec2a7c74edbf89e1148866 +README.md: dcfaf397ccfb61be72c2846cb3f678469645a2c7 +README.zh.md: 60ddb2901a51974cfeaccd8c820bc084b9ca0949 diff --git a/packages/tasks/tasks-local/README.md b/packages/tasks/tasks-local/README.md index 80d7932466..dcfaf397cc 100644 --- a/packages/tasks/tasks-local/README.md +++ b/packages/tasks/tasks-local/README.md @@ -2,7 +2,13 @@ English | [中文](README.zh.md) -Process-local implementation of the [`@deepseek-ai/dsh-tasks`](../tasks/README.md) registry contract: `LocalTaskService` keeps every record in memory, issues per-kind `-N` ids, and hands out fresh snapshots, never live state. It has no config; load it as a plugin and it registers as `ctx.tasks`. +Process-local implementation of the [`@deepseek-ai/dsh-tasks`](../tasks/README.md) registry contract: `LocalTaskService` keeps every record in memory, issues per-kind `-N` ids, and hands out fresh snapshots, never live state. Load it as a plugin and it registers as `ctx.tasks`. + +## Admission + +`maxConcurrentTasksPerOwner` is a positive safe integer and defaults to `10`. Before invoking a producer, `start()` counts the exact owner's `running` and `stopping` records; all unowned tasks share one separate service bucket. Terminal history does not occupy capacity, and only producer `done` settlement releases a stopping task's place. + +At capacity, `start()` fails before producer execution and id allocation with an error that names the limit and tells the model to use `task_kill`, wait for the task to finish stopping, and retry. The registry does not queue, preempt, or maintain a second mutable counter. ## Lifecycle diff --git a/packages/tasks/tasks-local/README.zh.md b/packages/tasks/tasks-local/README.zh.md index 5e4263e468..60ddb2901a 100644 --- a/packages/tasks/tasks-local/README.zh.md +++ b/packages/tasks/tasks-local/README.zh.md @@ -2,7 +2,13 @@ [English](README.md) | 中文 -[`@deepseek-ai/dsh-tasks`](../tasks/README.md) 注册表约定的进程本地实现:`LocalTaskService` 把每条记录保存在内存中,按 kind 签发 `-N` id,并且只交出全新快照,从不交出实时状态。它没有配置;作为插件加载后即注册为 `ctx.tasks`。 +[`@deepseek-ai/dsh-tasks`](../tasks/README.md) 注册表约定的进程本地实现:`LocalTaskService` 把每条记录保存在内存中,按 kind 签发 `-N` id,并且只交出全新快照,从不交出实时状态。作为插件加载后即注册为 `ctx.tasks`。 + +## 准入 + +`maxConcurrentTasksPerOwner` 必须是正的安全整数,默认值为 `10`。调用生产方之前,`start()` 会统计确切 owner 的 `running` 与 `stopping` 记录;所有无 owner 任务共享另一个独立的服务级桶。终止历史不占用容量,处于 `stopping` 的任务只有在生产方 `done` 结算后才释放名额。 + +达到容量时,`start()` 会在生产方执行和 id 分配前失败;错误会给出上限,并告诉模型使用 `task_kill`、等待任务完全停稳后再重试。注册表不会排队或抢占任务,也不会维护第二份可变计数。 ## 生命周期 diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json index cd40629948..b61ece6cb7 100644 --- a/packages/tasks/tasks-local/package.json +++ b/packages/tasks/tasks-local/package.json @@ -39,7 +39,12 @@ "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 6366024f74..81fc5bd363 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -10,6 +10,7 @@ */ import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeLayer } from '@deepseek-ai/dsh-scope' @@ -23,6 +24,18 @@ import type { /** Timeout code that distinguishes a bounded wait from caller cancellation. */ export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT' +/** Default maximum number of active tasks in one exact-owner bucket. */ +const DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER = 10 + +/** Configuration for the process-local task registry. */ +export interface Config { + /** Maximum `running` plus `stopping` tasks per exact owner; omission defaults to 10. */ + maxConcurrentTasksPerOwner?: number +} + +/** Configuration after defaults and load-time validation. */ +type ResolvedConfig = Required + /** The registry's mutable per-task record (never handed out — see {@link LocalTaskService.snapshot}). */ interface TrackedTask { id: TaskId @@ -76,6 +89,16 @@ class TaskLayer implements ScopeLayer { * semantics this implementation honors. */ export class LocalTaskService extends TaskService { + static Config: z = z.object({ + maxConcurrentTasksPerOwner: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER), + }) + + /** Validated registry configuration. */ + readonly config: ResolvedConfig private store = new Map() private counters = new Map() /** @@ -97,8 +120,14 @@ export class LocalTaskService extends TaskService { /** Service context used by detached settlement continuations and teardown. */ private readonly selfCtx: Context - constructor(ctx: Context) { + constructor(ctx: Context, config: Config = {}) { super(ctx) + const maxConcurrentTasksPerOwner = config.maxConcurrentTasksPerOwner + ?? DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER + if (!Number.isSafeInteger(maxConcurrentTasksPerOwner) || maxConcurrentTasksPerOwner <= 0) { + throw new TypeError('tasks-local: maxConcurrentTasksPerOwner must be a positive safe integer') + } + this.config = { maxConcurrentTasksPerOwner } this.selfCtx = ctx ctx.effect(() => () => this.disposeAll(), 'tasks teardown') } @@ -115,6 +144,13 @@ export class LocalTaskService extends TaskService { } if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner) + const active = this.activeTaskCount(spec.owner) + if (active >= this.config.maxConcurrentTasksPerOwner) { + throw new Error( + `background task limit reached for this owner (${active}/${this.config.maxConcurrentTasksPerOwner} active); use task_kill to stop an unneeded task, wait for it to finish, then retry`, + ) + } + const hooks = spec.run() const count = (this.counters.get(spec.kind) ?? 0) + 1 this.counters.set(spec.kind, count) @@ -285,6 +321,15 @@ export class LocalTaskService extends TaskService { .some(layer => !layer.surfaces.isEmpty()) } + /** Count authoritative active records for one exact owner or the shared unowned bucket. */ + private activeTaskCount(owner: Agent | undefined): number { + let count = 0 + for (const task of this.store.values()) { + if (task.owner === owner && (task.status === 'running' || task.status === 'stopping')) count += 1 + } + return count + } + /** * The completion listeners that own `owner`'s notices: the global layer's * first, then each scoped layer along the owner's chain. A listener outside diff --git a/packages/tasks/tasks-local/tests/loader-composition.spec.ts b/packages/tasks/tasks-local/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..0cd0cac575 --- /dev/null +++ b/packages/tasks/tasks-local/tests/loader-composition.spec.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Include from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +describe('tasks-local through a real Loader composition', () => { + it('applies the provider-owned admission config from a Cordis row', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-tasks-local-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-tasks-local'", + ' config:', + ' maxConcurrentTasksPerOwner: 2', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (specifier === '@deepseek-ai/dsh-tasks-local') return LocalTaskService + throw new Error(`unexpected Loader import: ${specifier}`) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + + expect(context.tasks).toBeInstanceOf(LocalTaskService) + expect((context.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(2) + }) +}) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index b4f0ced798..cd96ecdda4 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -7,7 +7,7 @@ import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { TaskId } from '@deepseek-ai/dsh-tasks' import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' -import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import LocalTaskService, { type Config as TasksConfig } from '@deepseek-ai/dsh-tasks-local' declare module '@deepseek-ai/dsh-tasks' { interface TaskKindMap { @@ -76,10 +76,10 @@ function producer(overrides: Partial & TaskHooks> = {}) { return { spec, settle, reject, cancels } } -async function harness() { +async function harness(config: TasksConfig = {}) { const ctx = new Context() await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalTaskService) + await ctx.plugin(LocalTaskService, config) ctx.tasks.attachSurface('test-surface') return ctx } @@ -166,6 +166,105 @@ describe('LocalTaskService.start', () => { expect(() => ctx.tasks.start(producer({ outputLimitBytes: 0 }).spec)).toThrow('outputLimitBytes') }) + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid maxConcurrentTasksPerOwner config: %s', + async (maxConcurrentTasksPerOwner) => { + const ctx = new Context() + await expect(ctx.plugin(LocalTaskService, { maxConcurrentTasksPerOwner })) + .rejects.toThrow() + expect(() => new LocalTaskService(new Context(), { maxConcurrentTasksPerOwner })) + .toThrow('maxConcurrentTasksPerOwner must be a positive safe integer') + }, + ) + + it('accepts the largest safe integer limit', async () => { + const ctx = await harness({ maxConcurrentTasksPerOwner: Number.MAX_SAFE_INTEGER }) + expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner) + .toBe(Number.MAX_SAFE_INTEGER) + }) + + it('defaults each owner bucket to ten active tasks', async () => { + const ctx = await harness() + expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(10) + const live = Array.from({ length: 10 }, () => producer()) + for (const task of live) ctx.tasks.start(task.spec) + + const blocked = producer() + const run = vi.fn(() => blocked.spec.run()) + expect(() => ctx.tasks.start({ ...blocked.spec, run })) + .toThrow('background task limit reached for this owner (10/10 active)') + expect(run).not.toHaveBeenCalled() + for (const task of live) task.settle({ status: 'completed' }) + }) + + it('rejects before producer start and id allocation, then admits immediately after settlement', async () => { + const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) + const first = producer() + expect(ctx.tasks.start(first.spec)).toBe('bash-1') + + const blocked = producer() + const run = vi.fn(() => blocked.spec.run()) + expect(() => ctx.tasks.start({ ...blocked.spec, run })) + .toThrow('use task_kill to stop an unneeded task, wait for it to finish, then retry') + expect(run).not.toHaveBeenCalled() + + first.settle({ status: 'completed' }) + await tick() + expect(ctx.tasks.start(blocked.spec)).toBe('bash-2') + }) + + it('keeps a stopping task in the bucket until producer settlement', async () => { + const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) + const first = producer() + const id = ctx.tasks.start(first.spec) + expect(ctx.tasks.kill(id)).toBe('requested') + + const replacement = producer() + expect(() => ctx.tasks.start(replacement.spec)).toThrow('(1/1 active)') + + first.settle({ status: 'killed' }) + await tick() + expect(ctx.tasks.start(replacement.spec)).toBe('bash-2') + }) + + it.each(['completed', 'killed', 'failed'] as const)( + 'releases the bucket after a %s terminal outcome', + async (status) => { + const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) + const first = producer() + ctx.tasks.start(first.spec) + first.settle({ status }) + await tick() + expect(() => ctx.tasks.start(producer().spec)).not.toThrow() + }, + ) + + it('isolates exact owners, replacement objects with the same session id, and the unowned bucket', async () => { + const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) + const oldOwner = stubAgent(ctx, 'shared-session') + const detachOld = ctx.agents.register(oldOwner) + const oldTask = producer({ owner: oldOwner }) + ctx.tasks.start(oldTask.spec) + + const otherOwner = stubAgent(ctx, 'other-session') + ctx.agents.register(otherOwner) + expect(() => ctx.tasks.start(producer({ owner: otherOwner }).spec)).not.toThrow() + + detachOld() + const replacement = stubAgent(ctx, 'shared-session') + ctx.agents.register(replacement) + expect(() => ctx.tasks.start(producer({ owner: replacement }).spec)).not.toThrow() + + ctx.tasks.start(producer().spec) + expect(() => ctx.tasks.start(producer().spec)).toThrow('(1/1 active)') + expect(() => ctx.tasks.start(producer({ owner: oldOwner }).spec)) + .toThrow('is not the registered agent instance') + + oldTask.settle({ status: 'completed' }) + await tick() + await disposeAgentScope(oldOwner) + }) + it('issues kind-prefixed ids from per-kind counters', async () => { const ctx = await harness() expect(ctx.tasks.start(producer().spec)).toBe('bash-1') diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml index 7c7ceba672..d7dd146996 100644 --- a/packages/tasks/tasks/README.i18n.yaml +++ b/packages/tasks/tasks/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/tasks/tasks/README.md -README.md: 053e407d2e28cb175ebe9de15c7e16ef04e95cb8 -README.zh.md: dd39b1098cd01d7db9db6e49210c2b1149b37ecb +README.md: 17579e417690dd34641e59f66cc8196465d08d68 +README.zh.md: 8f6ed21bb8ebcdcda4e0e3799aba5b88e5179393 diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index 053e407d2e..17579e4176 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -6,7 +6,7 @@ The background task registry contract (`ctx.tasks`). The abstract `TaskService` ## Service contract -- `start(spec): TaskId` validates the control surface, spec, exact live owner, and optional positive `outputLimitBytes` before calling the producer's `run()` once. A starter throw leaves nothing registered; successful return commits without another failable step. +- `start(spec): TaskId` validates the control surface, spec, exact live owner, optional positive `outputLimitBytes`, and any provider-owned admission policy before calling the producer's `run()` once. A preflight rejection or starter throw leaves no task id or registered work; successful return commits without another failable step. - `get(id, caller?)` and `list(caller?)` return non-consuming snapshots. Listing includes only caller-owned and unowned tasks. - `read(id, caller?)` consumes the single cursor for stream tasks and reads terminal output idempotently for final-output tasks. - `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported. diff --git a/packages/tasks/tasks/README.zh.md b/packages/tasks/tasks/README.zh.md index dd39b1098c..8f6ed21bb8 100644 --- a/packages/tasks/tasks/README.zh.md +++ b/packages/tasks/tasks/README.zh.md @@ -6,7 +6,7 @@ ## 服务约定 -- `start(spec): TaskId` 验证控制表层、spec、确切且仍存活的 owner,以及可选的 `outputLimitBytes`(如提供则须为正数),然后只调用生产方的 `run()` 一次。启动方抛出异常时不注册任何内容;成功返回会直接提交,不再执行其他可能失败的步骤。 +- `start(spec): TaskId` 验证控制表层、spec、确切且仍存活的 owner、可选的正数 `outputLimitBytes`,以及 Service provider 所拥有的准入策略,然后只调用生产方的 `run()` 一次。预检拒绝或启动方抛出异常时都不会生成 task id 或注册工作;成功返回会直接提交,不再执行其他可能失败的步骤。 - `get(id, caller?)` 和 `list(caller?)` 返回非消费式快照。列表只包含调用方拥有及无 owner 的任务。 - `read(id, caller?)` 消费流任务的唯一游标;对于最终输出任务,则以幂等方式读取终止输出。 - `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。 diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 06bb2e38fb..a8df67558a 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -67,10 +67,11 @@ export abstract class TaskService extends Service { } /** - * Preflight access, validation, and owner cleanup before starting and - * atomically registering work. A throwing starter leaves nothing registered; - * after it returns, registration cannot fail. Settlement records the outcome, - * notifies listeners, and releases waiters. + * Preflight access, validation, owner cleanup, and implementation-owned + * admission before starting and atomically registering work. Any preflight + * rejection leaves no task id or execution resource. A throwing starter + * leaves nothing registered; after it returns, registration cannot fail. + * Settlement records the outcome, notifies listeners, and releases waiters. * @param spec - task identity, owner, and synchronous starter. * @returns the registry-issued `-N` id. */ diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index 3221c20246..cf3b66e67e 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -572,6 +572,7 @@ describe('completion notices', () => { const prior = producer({ kind: 'pty-send' }) ctx.tasks.start(prior.spec) prior.settle({ status: 'completed' }) + await tick() } const inject = vi.fn() const owner = fakeAgent(ctx, 'sess-1', inject) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5612f5bc13..a209a4ee6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7129,10 +7129,20 @@ importers: version: link:../../core/session packages/tasks/tasks-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent From 9d173f8333da6013f6db1862c9093251eeb41b71 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 11 Aug 2026 16:57:04 +0800 Subject: [PATCH 11/61] test(tasks): cover direct admission default --- packages/tasks/tasks-local/tests/tasks.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 73c502cd46..12cf6f917c 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -197,6 +197,10 @@ describe('LocalTaskService.start', () => { for (const task of live) task.settle({ status: 'completed' }) }) + it('defaults direct construction when the config schema is bypassed', () => { + expect(new LocalTaskService(new Context()).config.maxConcurrentTasksPerOwner).toBe(10) + }) + it('rejects before producer start and id allocation, then admits immediately after settlement', async () => { const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) const first = producer() From c98a4d09493350b546f8449295c15f5938aecda2 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 11 Aug 2026 17:12:53 +0800 Subject: [PATCH 12/61] refactor(tasks): simplify admission configuration --- .../background-task-admission/session.jsonl | 2 +- .../examples/acp-demo/tests/acp-agent.spec.ts | 18 +++++++++++++--- .../agent-spine-demo/tests/agent-core.spec.ts | 19 +++++++++++++---- packages/tasks/tasks-local/src/index.ts | 21 +++++++------------ .../tests/loader-composition.spec.ts | 18 ++++++++++++++-- .../tasks/tasks-local/tests/tasks.spec.ts | 16 ++++---------- 6 files changed, 58 insertions(+), 36 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl b/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl index 0a303e1866..d9d40b2539 100644 --- a/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl +++ b/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl @@ -25,7 +25,7 @@ {"type":"assistant/chunk","seq":23,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":24,"time":1786434813605,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"48c909b3-5651-462f-b0d3-09198d119a2f"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","seq":25,"time":1786434813606,"data":{"turn":1,"step":2,"callId":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}} -{"type":"tool/result","seq":26,"time":1786434813609,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"bounded-task-second"},"content":[{"type":"tool-result","toolCallId":"bounded-task-second","content":[{"type":"text","text":"Error: background task limit reached for this owner (1/1 active); use task_kill to stop an unneeded task, wait for it to finish, then retry"}],"isError":true}],"role":"user","id":"982adb5c-13ef-44c5-a53e-b726c986ac68"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"tool/result","seq":26,"time":1786434813609,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"bounded-task-second"},"content":[{"type":"tool-result","toolCallId":"bounded-task-second","content":[{"type":"text","text":"Error: background task limit reached for this owner (limit: 1); use task_kill to stop an unneeded task, wait for it to finish, then retry"}],"isError":true}],"role":"user","id":"c0386bdf-df3c-4d2b-af8e-04ee28682214"}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","seq":27,"time":1786434813609,"data":{"turn":1,"step":2}} {"type":"step/start","seq":28,"time":1786434813614,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":29,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 65479f0489..3a64404704 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -186,12 +186,24 @@ describe('dsh-acp-demo composition', () => { const ctx = await mount({ provider: 'mock', model: 'mock', - tasks: { maxConcurrentTasksPerOwner: 2 }, + tasks: { maxConcurrentTasksPerOwner: 1 }, skills: await isolatedSkillsConfig(), workspaceContext: false, }) - expect((ctx.tasks as unknown as { config: { maxConcurrentTasksPerOwner: number } }) - .config.maxConcurrentTasksPerOwner).toBe(2) + let settle!: (outcome: { status: 'killed' }) => void + ctx.tasks.start({ + kind: 'bash', + label: 'hold configured slot', + run: () => ({ + cancel: () => { settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + }), + }) + expect(() => ctx.tasks.start({ + kind: 'bash', + label: 'blocked configured task', + run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }), + })).toThrow('(limit: 1)') await ctx.fiber.dispose() }) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index d3b02ae582..9f738e3b56 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -10,7 +10,6 @@ import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' -import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { @@ -158,7 +157,6 @@ describe('dsh-agent-spine-demo bundle', () => { expect(ctx.get('skills')).toBeDefined() expect(ctx.get('agents')).toBeDefined() expect(ctx.get('tasks')).toBeDefined() - expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(10) expect(ctx.get('invariants')).toBeDefined() expect(ctx.get('agentLoop')).toBeDefined() expect(ctx.get('goals')).toBeUndefined() @@ -304,10 +302,23 @@ describe('dsh-agent-spine-demo bundle', () => { it('forwards task admission config to the process-local provider', async () => { const ctx = await mount({ - tasks: { maxConcurrentTasksPerOwner: 3 }, + tasks: { maxConcurrentTasksPerOwner: 1 }, workspaceContext: false, }) - expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(3) + let settle!: (outcome: { status: 'killed' }) => void + ctx.tasks.start({ + kind: 'probe', + label: 'hold configured slot', + run: () => ({ + cancel: () => { settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + }), + }) + expect(() => ctx.tasks.start({ + kind: 'probe', + label: 'blocked configured task', + run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }), + })).toThrow('(limit: 1)') await ctx.fiber.dispose() }) diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index c4f90808a6..cf60c416fc 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -33,9 +33,6 @@ export interface Config { maxConcurrentTasksPerOwner?: number } -/** Configuration after defaults and load-time validation. */ -type ResolvedConfig = Required - /** The registry's mutable per-task record (never handed out — see {@link LocalTaskService.snapshot}). */ interface TrackedTask { id: TaskId @@ -97,8 +94,8 @@ export class LocalTaskService extends TaskService { .default(DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER), }) - /** Validated registry configuration. */ - readonly config: ResolvedConfig + /** Schemastery-defaulted active-task limit. */ + private readonly maxConcurrentTasksPerOwner: number private store = new Map() private counters = new Map() /** @@ -120,14 +117,10 @@ export class LocalTaskService extends TaskService { /** Service context used by detached settlement continuations and teardown. */ private readonly selfCtx: Context - constructor(ctx: Context, config: Config = {}) { + constructor(ctx: Context, config: Config) { super(ctx) - const maxConcurrentTasksPerOwner = config.maxConcurrentTasksPerOwner - ?? DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER - if (!Number.isSafeInteger(maxConcurrentTasksPerOwner) || maxConcurrentTasksPerOwner <= 0) { - throw new TypeError('tasks-local: maxConcurrentTasksPerOwner must be a positive safe integer') - } - this.config = { maxConcurrentTasksPerOwner } + // Schemastery validates and fills the default before constructing the service. + this.maxConcurrentTasksPerOwner = (config as Required).maxConcurrentTasksPerOwner this.selfCtx = ctx ctx.effect(() => () => this.disposeAll(), 'tasks teardown') } @@ -145,9 +138,9 @@ export class LocalTaskService extends TaskService { if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner) const active = this.activeTaskCount(spec.owner) - if (active >= this.config.maxConcurrentTasksPerOwner) { + if (active >= this.maxConcurrentTasksPerOwner) { throw new Error( - `background task limit reached for this owner (${active}/${this.config.maxConcurrentTasksPerOwner} active); use task_kill to stop an unneeded task, wait for it to finish, then retry`, + `background task limit reached for this owner (limit: ${this.maxConcurrentTasksPerOwner}); use task_kill to stop an unneeded task, wait for it to finish, then retry`, ) } diff --git a/packages/tasks/tasks-local/tests/loader-composition.spec.ts b/packages/tasks/tasks-local/tests/loader-composition.spec.ts index 0cd0cac575..9d4b09b6ff 100644 --- a/packages/tasks/tasks-local/tests/loader-composition.spec.ts +++ b/packages/tasks/tasks-local/tests/loader-composition.spec.ts @@ -25,7 +25,7 @@ describe('tasks-local through a real Loader composition', () => { await writeFile(configPath, [ "- name: '@deepseek-ai/dsh-tasks-local'", ' config:', - ' maxConcurrentTasksPerOwner: 2', + ' maxConcurrentTasksPerOwner: 1', '', ].join('\n')) @@ -47,6 +47,20 @@ describe('tasks-local through a real Loader composition', () => { await context.loader.await() expect(context.tasks).toBeInstanceOf(LocalTaskService) - expect((context.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(2) + context.tasks.attachController('loader-test') + let settle!: (outcome: { status: 'killed' }) => void + context.tasks.start({ + kind: 'bash', + label: 'hold loader slot', + run: () => ({ + cancel: () => { settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + }), + }) + expect(() => context!.tasks.start({ + kind: 'bash', + label: 'blocked loader task', + run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }), + })).toThrow('(limit: 1)') }) }) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 12cf6f917c..239fca4fb3 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -172,35 +172,27 @@ describe('LocalTaskService.start', () => { const ctx = new Context() await expect(ctx.plugin(LocalTaskService, { maxConcurrentTasksPerOwner })) .rejects.toThrow() - expect(() => new LocalTaskService(new Context(), { maxConcurrentTasksPerOwner })) - .toThrow('maxConcurrentTasksPerOwner must be a positive safe integer') }, ) it('accepts the largest safe integer limit', async () => { const ctx = await harness({ maxConcurrentTasksPerOwner: Number.MAX_SAFE_INTEGER }) - expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner) - .toBe(Number.MAX_SAFE_INTEGER) + expect(ctx.tasks).toBeInstanceOf(LocalTaskService) }) it('defaults each owner bucket to ten active tasks', async () => { const ctx = await harness() - expect((ctx.tasks as LocalTaskService).config.maxConcurrentTasksPerOwner).toBe(10) const live = Array.from({ length: 10 }, () => producer()) for (const task of live) ctx.tasks.start(task.spec) const blocked = producer() const run = vi.fn(() => blocked.spec.run()) expect(() => ctx.tasks.start({ ...blocked.spec, run })) - .toThrow('background task limit reached for this owner (10/10 active)') + .toThrow('background task limit reached for this owner (limit: 10)') expect(run).not.toHaveBeenCalled() for (const task of live) task.settle({ status: 'completed' }) }) - it('defaults direct construction when the config schema is bypassed', () => { - expect(new LocalTaskService(new Context()).config.maxConcurrentTasksPerOwner).toBe(10) - }) - it('rejects before producer start and id allocation, then admits immediately after settlement', async () => { const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) const first = producer() @@ -224,7 +216,7 @@ describe('LocalTaskService.start', () => { expect(ctx.tasks.kill(id)).toBe('requested') const replacement = producer() - expect(() => ctx.tasks.start(replacement.spec)).toThrow('(1/1 active)') + expect(() => ctx.tasks.start(replacement.spec)).toThrow('(limit: 1)') first.settle({ status: 'killed' }) await tick() @@ -260,7 +252,7 @@ describe('LocalTaskService.start', () => { expect(() => ctx.tasks.start(producer({ owner: replacement }).spec)).not.toThrow() ctx.tasks.start(producer().spec) - expect(() => ctx.tasks.start(producer().spec)).toThrow('(1/1 active)') + expect(() => ctx.tasks.start(producer().spec)).toThrow('(limit: 1)') expect(() => ctx.tasks.start(producer({ owner: oldOwner }).spec)) .toThrow('is not the registered agent instance') From 526febc44d06be1112452421485c2a494dcf380e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 16:58:40 +0800 Subject: [PATCH 13/61] feat(feedback): add the Web surface for message feedback Consume the durable message-feedback sidecar from #2217 in the browser: per-message Like/Dislike with an optional note, contributed through a declared assistant-actions slot. - carry MessageId on finalized AssistantMessageNode so a target is nameable - declare conversation.chat.assistant-actions and render it in the IconActions row between copy and branch - hold one FeedbackController per Session with per-item ifVersion CAS, reconciling a version-conflict from the reply's authoritative item - mount messageFeedbackRemote alongside goalsRemote --- ...-11-message-feedback-web-surface.i18n.yaml | 6 + ...2026-08-11-message-feedback-web-surface.md | 55 ++++ ...6-08-11-message-feedback-web-surface.zh.md | 55 ++++ apps/web/tests/message-feedback.e2e.ts | 121 ++++++++ .../snapshots/code-mode-round/ui.expected.md | 4 + .../cordis-tool-round/ui.expected.md | 4 + .../feedback-command/ack.expected.md | 4 + .../snapshots/fresh-round-trip/ui.expected.md | 4 + .../goal-multi-turn-actions/ui.expected.md | 8 + .../lifecycle-chrome/reloaded.expected.md | 4 + .../live-interactions/retry.expected.md | 4 + .../markdown-cjk-strong/ui.expected.md | 4 + .../snapshots/markdown-images/ui.expected.md | 4 + .../markdown-inline-code-links/ui.expected.md | 4 + .../snapshots/math-rendering/ui.expected.md | 4 + .../snapshots/message-actions/ui.expected.md | 8 + .../plan-review/approved.expected.md | 4 + .../question-composer/answered.expected.md | 4 + .../seeded-history/command-row.expected.md | 4 + .../seeded-history/feedback-row.expected.md | 4 + .../snapshots/seeded-history/ui.expected.md | 4 + .../snapshots/skill-tool-row/ui.expected.md | 4 + .../skill-user-invoke/ui.expected.md | 4 + .../snapshots/steer-all/settled.expected.md | 4 + .../snapshots/steering/settled.expected.md | 4 + .../subagent-conversation/ui.expected.md | 8 + .../snapshots/web-search-round/ui.expected.md | 4 + apps/web/tsconfig.json | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 13 +- docs/module-graph.zh.md | 13 +- docs/subsystems/feedback.i18n.yaml | 4 +- docs/subsystems/feedback.md | 12 +- docs/subsystems/feedback.zh.md | 12 +- packages/api/remotes/package.json | 2 + packages/api/remotes/src/client/index.ts | 10 +- packages/api/remotes/tsconfig.client.json | 3 + packages/bundle/web-app/cordis.patch.yml | 5 + packages/bundle/web-app/package.json | 1 + .../src/client/sessions/conversation.ts | 6 + .../src/client/chat/MessageIconActions.tsx | 11 +- .../src/client/chat/TurnTailNodeView.tsx | 12 +- .../client/chat/register-node-renderers.ts | 5 +- .../src/client/contract/slots.ts | 23 ++ .../client/conversation-nodes/assistant.ts | 1 + packages/client/ui-feedback/README.i18n.yaml | 6 + packages/client/ui-feedback/README.md | 25 ++ packages/client/ui-feedback/README.zh.md | 25 ++ packages/client/ui-feedback/package.json | 82 ++++++ .../src/client/FeedbackActions.module.css | 108 +++++++ .../src/client/FeedbackActions.tsx | 140 +++++++++ .../ui-feedback/src/client/controller.ts | 267 +++++++++++++++++ .../client/ui-feedback/src/client/index.ts | 84 ++++++ .../client/ui-feedback/src/client/locales.ts | 41 +++ .../client/ui-feedback/src/client/slots.ts | 51 ++++ .../client/ui-feedback/src/css-modules.d.ts | 6 + packages/client/ui-feedback/src/index.ts | 9 + packages/client/ui-feedback/src/invariant.ts | 33 +++ .../ui-feedback/tests/browser-plugin.spec.tsx | 197 +++++++++++++ .../ui-feedback/tests/controller.spec.ts | 273 ++++++++++++++++++ .../tests/feedback-actions.spec.tsx | 172 +++++++++++ packages/client/ui-feedback/tsconfig.json | 42 +++ packages/client/ui-feedback/tsdown.config.ts | 3 + .../client/trajectory-assistant-definition.ts | 1 + pnpm-lock.yaml | 63 +++- .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + 72 files changed, 2095 insertions(+), 22 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.md create mode 100644 .agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.zh.md create mode 100644 apps/web/tests/message-feedback.e2e.ts create mode 100644 packages/client/ui-feedback/README.i18n.yaml create mode 100644 packages/client/ui-feedback/README.md create mode 100644 packages/client/ui-feedback/README.zh.md create mode 100644 packages/client/ui-feedback/package.json create mode 100644 packages/client/ui-feedback/src/client/FeedbackActions.module.css create mode 100644 packages/client/ui-feedback/src/client/FeedbackActions.tsx create mode 100644 packages/client/ui-feedback/src/client/controller.ts create mode 100644 packages/client/ui-feedback/src/client/index.ts create mode 100644 packages/client/ui-feedback/src/client/locales.ts create mode 100644 packages/client/ui-feedback/src/client/slots.ts create mode 100644 packages/client/ui-feedback/src/css-modules.d.ts create mode 100644 packages/client/ui-feedback/src/index.ts create mode 100644 packages/client/ui-feedback/src/invariant.ts create mode 100644 packages/client/ui-feedback/tests/browser-plugin.spec.tsx create mode 100644 packages/client/ui-feedback/tests/controller.spec.ts create mode 100644 packages/client/ui-feedback/tests/feedback-actions.spec.tsx create mode 100644 packages/client/ui-feedback/tsconfig.json create mode 100644 packages/client/ui-feedback/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.i18n.yaml new file mode 100644 index 0000000000..b6cca60d72 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.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-08-11-message-feedback-web-surface.md +2026-08-11-message-feedback-web-surface.md: 77d21796762ce024f80fd46a7eeeea998fe94753 +2026-08-11-message-feedback-web-surface.zh.md: a6a6248c950070ebc131d285e8008313bf76d374 diff --git a/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.md b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.md new file mode 100644 index 0000000000..77d2179676 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.md @@ -0,0 +1,55 @@ +# Agent Note: Web surface for message feedback + +Status: implemented + +English | [中文](2026-08-11-message-feedback-web-surface.zh.md) + +## Problem + +[PR #2217](https://github.com/deepseek-harness/deepseek-harness/pull/2217) landed the durable message-feedback sidecar and its three Host Remote methods, but it was explicitly backend-only: no client package consumed `messageFeedback.list`, `put`, or `delete`, so the Web GUI had no way to record a rating. Its Agent Note deferred "client Remote aggregate mounting and UI" to a separate owner. Issue #1326 asks for the Web surface and was closed by that backend merge without the user-visible half existing. + +An earlier full-stack attempt, [PR #1010](https://github.com/deepseek-harness/deepseek-harness/pull/1010), carried a UI layer but was built against its own backend with a different shape: one Session-wide `revision` for compare-and-swap and RPC named `feedback.upsert`. #2217 shipped per-item `ifVersion` and `messageFeedback.put` instead, so #1010's controller logic no longer matched the contract, and its branch had also drifted structurally (it edited `packages/cordis/`, renamed to `packages/self-modification/`, and added a top-level `packages/session-feedback/` that conflicts with the consolidated `packages/feedback/`). It was closed as superseded rather than rebased. + +The blocking gap for any UI was that the browser could not name a feedback target. The Host accepts only an append-origin `assistant/message` addressed by `MessageId`, but `AssistantMessageNode` — the client's finalized-assistant node — carried `seq`, `turn`, and `step` and no message identity. Only `SteeringMessageNode` had a `messageId`. + +## Decision + +Three seams, each owned where its authority already lives. + +**Message identity in the client node.** `AssistantMessageNode` gains an optional `messageId`, copied from `event.data.message.id` where the node is materialized from a finalized `assistant/message`. It stays absent on interruption-frozen partials, which were never finalized and address no durable message, and on the synthetic sentinel the trajectory layout builds for an unfinalized partial. The field is optional precisely so those two cases remain unrepresentable as feedback targets rather than being papered over with a placeholder. `ui-conversation` and `ui-trajectory` each materialize their own copy of this node, so both finalized branches were updated; the interrupted branches were deliberately left alone. This mirrors the Host's own target rule, which filters on `isAppendSurfaceEvent`, so client and Host agree on what is addressable without sharing code. + +**A declared slot rather than a direct dependency.** `ui-conversation` declares `conversation.chat.assistant-actions` (list kind, session scope, owner `{messageId}`) and authorizes it as a second child of the `turn-tail` node renderer, next to the existing `conversation.chat.turnTail` chain. `TurnTailNodeView` renders it and threads the result into `MessageIconActions` through a new `extraActions` prop, placed between copy and branch. The render site skips the slot entirely when `messageId` is absent, so an interrupted turn shows no controls. The feedback package therefore contributes an entry and never imports the conversation implementation; the strip renders nothing at zero cost when the plugin is composed out of `cordis.yml`. + +`extraActions` is a `ReactNode` prop rather than a second render-slot hole because `MessageIconActions` is shared chrome for user and assistant messages: the assistant caller resolves the slot and passes the result down, so the user path stays unaware of a slot it must never render. + +**Per-item CAS in a per-session controller.** `@deepseek-ai/dsh-client-ui-feedback` holds one `FeedbackController` per Session, keyed by `MessageId` in a map. A single `list` seeds every control in that Session's transcript. Each mutation sends the version that controller last observed as `ifVersion` — `null` when it knows of no item, which is exactly the Host's "must not exist" precondition. + +The conflict path is where this diverges most from #1010. `MessageFeedbackVersionConflict` carries the authoritative `current` item (or `null`), so a lost race reconciles from the reply itself; #1010 answered every conflict with a blind full refresh. A conflict reporting `current: null` deletes the local entry, which is how a rating removed in another tab disappears here. Mutations serialize on a per-Session tail so a queued operation always compares against the committed version rather than the version read when the click landed. + +The list read is deferred to the first hover or focus, not fired on mount, because the controls mount once per settled message in the visible history; a transcript-wide read on mount would fan out one request per message strip. `connection/reset` refreshes only Sessions whose status is no longer `cold`, so a reconnect does not warm Sessions nobody has looked at. + +Toggle semantics keep the two verbs honest: re-clicking the recorded rating calls `delete`, switching sides calls `put` and carries any existing note forward, and clearing a message with no known item returns success without a call because it is already in the requested state. + +**Remote mounting.** `@deepseek-ai/dsh-api-remotes` now mounts `messageFeedbackRemote` alongside `goalsRemote` and composes both disposers in reverse order. The generated `./remote` artifact already existed in #2217's package exports, so no codegen change was needed; the client calls `ctx.remote.messageFeedback` and never touches the transport. Business results cross this boundary as the ordinary tagged union — the gateway throws only on transport failure — so the controller pattern-matches `ok` and translates a throw into the same settled result shape the controls already render. + +## Alternatives considered + +**Reuse `conversation.chat.turnTail` instead of a new slot.** Rejected: `turnTail` is a chain keyed on the Turn and carries `TurnTailOwnerProps {turn, seq, openFile}`, which addresses a Turn boundary rather than a message identity. Feedback needs `MessageId`, and a chain is selector-routed one-at-a-time where the action strip is genuinely a list of independent contributors. + +**Put `messageId` on the chat node's `id` field.** Rejected: that id is `"${turn}:${step}"` and is load-bearing for keyed dispatch and stable React keys. Overloading it would couple node identity to model output identity, and a message id is not unique per node anyway once replacement-origin events exist. + +**Keep #1010's session-wide revision.** Not available: the merged Host contract is per-item `ifVersion`. Even as a client-side simplification it would be worse — one Session revision makes unrelated per-message edits conflict, which is the precise problem #2217's Agent Note records as the reason for per-item versions. + +**Rebase #1010.** Rejected after inspection: 102 files, `mergeable: false`, a duplicate backend and RPC layer that #2217 supersedes under different names, and two directory renames since. Only its ~1,400-line UI layer had residual value, and that layer called `feedback.upsert` with a revision it no longer has. Rewriting the UI against the merged contract was less work than reconciling the branch, and the closing comment on #1010 records that reasoning. + +## Consequences + +The Web GUI records per-message ratings and notes. #1326's user-visible half now exists; the issue was reopened because the backend merge had closed it while no entry point existed. + +`AssistantMessageNode.messageId` is optional, so every existing reader compiles unchanged, but any future consumer must handle absence rather than assume a finalized message. The two parallel materializers remain a duplication hazard: a third view that builds this node must remember to copy the id, and nothing enforces it. Only the chat view renders controls today, even though trajectory and waterfall nodes now carry the same id. + +Feedback stays invisible to the model — the sidecar reaches neither the Session log, model context, nor telemetry — so the package's Model Experience is an audited `none` entry rather than a structured block. + +The sidecar publishes no live frames, so a second tab's rating surfaces on reconnect or on the next conflict reply, not immediately. The note editor does not pre-check `maxNoteBytes` (8192 in the Web bundle), so an oversized note fails on save with `note-too-large` rather than while typing. + +Nine existing Web UI snapshots gained the two rating buttons, confirming the strip reaches every settled assistant message in the shipped composition rather than only the fixture under test. diff --git a/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.zh.md b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.zh.md new file mode 100644 index 0000000000..a6a6248c95 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-message-feedback-web-surface.zh.md @@ -0,0 +1,55 @@ +# Agent Note:消息反馈的 Web 界面 + +Status: implemented + +[English](2026-08-11-message-feedback-web-surface.md) | 中文 + +## 问题 + +[PR #2217](https://github.com/deepseek-harness/deepseek-harness/pull/2217) 交付了持久化的消息反馈 sidecar 及其三个 Host Remote 方法,但它明确只做后端:没有任何客户端包消费 `messageFeedback.list`、`put` 或 `delete`,因此 Web GUI 无法记录评价。它的 Agent Note 把「客户端 Remote aggregate 挂载与 UI」留给了另一个负责人。Issue #1326 要求的正是 Web 界面,却在该后端合并时被关闭,而用户可见的那一半并不存在。 + +更早的全栈尝试 [PR #1010](https://github.com/deepseek-harness/deepseek-harness/pull/1010) 带有 UI 层,但它基于自己的后端、形状不同:整个 Session 一个 `revision` 做 compare-and-swap,RPC 名为 `feedback.upsert`。#2217 最终交付的是逐条 `ifVersion` 与 `messageFeedback.put`,因此 #1010 的 controller 逻辑不再匹配契约;它的分支在结构上也已漂移(改动了 `packages/cordis/`,该目录已重命名为 `packages/self-modification/`;新增的顶层 `packages/session-feedback/` 与整合后的 `packages/feedback/` 冲突)。它作为 superseded 关闭,而不是 rebase。 + +任何 UI 的阻塞缺口在于浏览器无法指名一个反馈目标。Host 只接受以 `MessageId` 寻址的 append 来源 `assistant/message`,但 `AssistantMessageNode`——客户端表示已完成 assistant 输出的节点——只携带 `seq`、`turn`、`step`,没有消息身份。只有 `SteeringMessageNode` 有 `messageId`。 + +## 决策 + +三个接缝,各自归属于其权威已经所在的位置。 + +**客户端节点中的消息身份。** `AssistantMessageNode` 增加可选的 `messageId`,在该节点由已完成的 `assistant/message` 物化时从 `event.data.message.id` 复制。它在被中断冻结的部分输出上保持缺失——那些从未完成、不指向任何持久消息——在 trajectory 布局为未完成部分输出构造的合成哨兵上同样缺失。该字段之所以可选,正是为了让这两种情况无法被表示为反馈目标,而不是用占位值掩盖过去。`ui-conversation` 与 `ui-trajectory` 各自物化自己的该节点副本,因此两条「已完成」分支都做了更新;「被中断」分支被有意保留原样。这与 Host 自身的目标规则一致——它按 `isAppendSurfaceEvent` 过滤——因此客户端与 Host 在「什么是可寻址的」上取得一致,而不需要共享代码。 + +**声明式槽位而非直接依赖。** `ui-conversation` 声明 `conversation.chat.assistant-actions`(list 类型、session 作用域、owner 为 `{messageId}`),并把它授权为 `turn-tail` 节点渲染器的第二个子项,与既有的 `conversation.chat.turnTail` 链并列。`TurnTailNodeView` 渲染它,并通过新的 `extraActions` prop 把结果传入 `MessageIconActions`,位置在复制与分支之间。当 `messageId` 缺失时渲染点整体跳过该槽位,因此被中断的 Turn 不显示任何控件。反馈包因此只贡献一个 entry,从不引入 conversation 的实现;当该插件从 `cordis.yml` 组装中移除时,这条操作栏以零成本渲染为空。 + +`extraActions` 是一个 `ReactNode` prop 而不是第二个 render-slot 洞,因为 `MessageIconActions` 是用户消息与 assistant 消息共享的外壳:由 assistant 一侧解析槽位并把结果向下传递,用户路径则对这个它永远不该渲染的槽位保持无感。 + +**per-session controller 中的逐条 CAS。** `@deepseek-ai/dsh-client-ui-feedback` 为每个 Session 持有一个 `FeedbackController`,以 `MessageId` 为键存入 map。一次 `list` 为该 Session 转录中的所有控件播种。每次 mutation 发送该 controller 最后观察到的版本作为 `ifVersion`——当它不知道任何条目时为 `null`,这正是 Host 的「必须不存在」前置条件。 + +冲突路径是与 #1010 分歧最大的地方。`MessageFeedbackVersionConflict` 携带权威的 `current` 条目(或 `null`),因此竞争失败方直接从回复本身收敛;#1010 对每次冲突都以一次盲目的全量刷新作答。报告 `current: null` 的冲突会删除本地条目,这就是在另一个标签页中被移除的评价在此处消失的方式。mutation 在 per-Session 的尾部串行化,因此排队中的操作总是与已提交的版本比较,而不是与点击落下那一刻读到的版本比较。 + +list 读取被推迟到首次 hover 或 focus,而不是在 mount 时触发,因为控件会为可见历史中每条已结算消息各 mount 一次;在 mount 时做全转录读取会导致每条消息栏各发一个请求。`connection/reset` 只刷新状态不再是 `cold` 的 Session,因此重连不会预热没人看过的 Session。 + +切换语义让两个动词保持诚实:再次点击已记录的评价调用 `delete`,切换到另一侧调用 `put` 并携带已有备注,而对没有已知条目的消息执行清除会直接返回成功且不发起调用,因为它已处于被请求的状态。 + +**Remote 挂载。** `@deepseek-ai/dsh-api-remotes` 现在把 `messageFeedbackRemote` 与 `goalsRemote` 并列挂载,并以相反顺序组合两个 disposer。生成的 `./remote` 产物在 #2217 的包导出中已存在,因此不需要 codegen 改动;客户端调用 `ctx.remote.messageFeedback`,从不接触传输层。业务结果以普通的 tagged union 穿过该边界——gateway 只在传输失败时抛出——因此 controller 对 `ok` 做模式匹配,并把抛出翻译为控件已经在渲染的同一种结算结果形状。 + +## 考虑过的替代方案 + +**复用 `conversation.chat.turnTail` 而不新增槽位。** 否决:`turnTail` 是以 Turn 为键的链,携带 `TurnTailOwnerProps {turn, seq, openFile}`,寻址的是 Turn 边界而非消息身份。反馈需要 `MessageId`,而链是选择器路由的一次一个,操作栏则确实是一组互相独立的贡献者的列表。 + +**把 `messageId` 放到 chat 节点的 `id` 字段上。** 否决:该 id 是 `"${turn}:${step}"`,且承载着 keyed dispatch 与稳定 React key 的作用。重载它会把节点身份与模型输出身份耦合起来,而且一旦存在 replacement 来源的事件,消息 id 本身在每个节点上也并非唯一。 + +**保留 #1010 的 session 级 revision。** 不可行:已合并的 Host 契约是逐条 `ifVersion`。即便作为客户端侧的简化也更糟——单一 Session revision 会让互不相关的逐条编辑相互冲突,而这正是 #2217 的 Agent Note 记录的采用逐条版本的原因。 + +**Rebase #1010。** 经检查后否决:102 个文件、`mergeable: false`、一个被 #2217 以不同名称取代的重复后端与 RPC 层,以及此后的两次目录重命名。只有其约 1400 行的 UI 层有残余价值,而该层调用的 `feedback.upsert` 及其 revision 已不复存在。基于已合并的契约重写 UI 比调和该分支更省力,#1010 的关闭评论记录了这一理由。 + +## 结果 + +Web GUI 可以记录逐条消息的评价与备注。#1326 中用户可见的那一半现在存在了;该 Issue 之所以被重开,是因为后端合并在没有任何入口存在的情况下关闭了它。 + +`AssistantMessageNode.messageId` 是可选的,因此所有既有读取方无需改动即可编译,但任何将来的消费方都必须处理缺失,而不能假定消息已完成。两个并行的物化点仍是重复隐患:第三个构造该节点的视图必须记得复制该 id,而没有任何机制强制这一点。今天只有 chat 视图渲染控件,尽管 trajectory 与 waterfall 节点现在携带同一个 id。 + +反馈对模型保持不可见——该 sidecar 既不进入 Session 日志、也不进入模型上下文与 telemetry——因此该包的 Model Experience 是一条经审计的 `none` 条目,而不是结构化区块。 + +该 sidecar 不发布实时帧,因此第二个标签页的评价会在重连时或下一次冲突回复时才浮现,而不是立即。备注编辑器不预先校验 `maxNoteBytes`(Web bundle 中为 8192),因此过大的备注会在保存时以 `note-too-large` 失败,而不是在输入过程中。 + +九个既有 Web UI 快照获得了这两个评价按钮,确认这条操作栏在已发布的组装中触达每条已结算的 assistant 消息,而不仅是被测试的那个 fixture。 diff --git a/apps/web/tests/message-feedback.e2e.ts b/apps/web/tests/message-feedback.e2e.ts new file mode 100644 index 0000000000..69c5ad54bb --- /dev/null +++ b/apps/web/tests/message-feedback.e2e.ts @@ -0,0 +1,121 @@ +// Keyless browser regression for durable per-message feedback. Cold-seeds a +// settled two-turn transcript (zero model calls), rates one assistant message, +// attaches a note, proves both survive a full page reload from the Host's +// message-feedback sidecar, then retracts the rating. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + acknowledgeReloadConnectionLoss, launchWebScaffold, + seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +// Borrowed read-only: this scenario needs any settled assistant message to +// address, not a new recording (message-actions / sidebar-scrollbar pattern). +const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'message-feedback-web-e2e' +const NOTE = 'Read both files before answering.' + +describe('web e2e: durable per-message feedback', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + /** + * Open the seeded transcript. The first treeitem is the collapsible group + * row; the session itself is the row beneath it. The group is already + * expanded on a fresh load, so clicking it unconditionally would collapse it + * and hide the session row. + */ + async function openSeededSession(): Promise { + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 15_000 }) + await sessionRow.click() + } + + it.skipIf(MODE === 'record')('persists a rating and its note across a reload, then retracts', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback')) + await openSeededSession() + + // The controls live in the assistant message's IconActions row, which the + // transcript reveals on hover/focus like copy and branch. Wait for the + // settled closing text first: the strip mounts with that turn's tail. + await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 }) + const like = page.getByRole('button', { name: 'Good response' }).first() + await like.waitFor({ timeout: 30_000 }) + await like.scrollIntoViewIfNeeded() + await like.hover() + await like.click() + // A recorded rating relabels the button to what the next click would do, + // so the pressed control is addressed by the retract label from here on. + const rated = page.getByRole('button', { name: 'Remove rating' }).first() + await expect.poll(() => rated.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('true') + + // A rated message offers the note editor; an unrated one does not. + await page.getByRole('button', { name: 'Add a note' }).first().click() + const editor = page.getByRole('textbox', { name: 'Feedback note' }) + await editor.fill(NOTE) + await page.getByRole('button', { name: 'Save', exact: true }).click() + await expect.poll(() => editor.count(), { timeout: 10_000 }).toBe(0) + await page.getByText(NOTE, { exact: true }).waitFor({ timeout: 10_000 }) + + // The durable assertion: a cold browser re-reads the sidecar over the wire. + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await openSeededSession() + await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 }) + + // The controller defers its list read to the first hover or focus, so a + // cold reload shows the unrated label until the strip is touched. Hovering + // the unrated control is what triggers the authoritative re-read. + const cold = page.getByRole('button', { name: 'Good response' }).first() + await cold.waitFor({ timeout: 30_000 }) + await cold.scrollIntoViewIfNeeded() + await cold.hover() + + const restored = page.getByRole('button', { name: 'Remove rating' }).first() + await restored.waitFor({ timeout: 30_000 }) + await restored.scrollIntoViewIfNeeded() + await restored.hover() + await expect.poll(() => restored.getAttribute('aria-pressed'), { timeout: 15_000 }).toBe('true') + await page.getByText(NOTE, { exact: true }).waitFor({ timeout: 10_000 }) + + // Re-clicking the active rating retracts it, and the note goes with it. + await restored.click() + await expect.poll( + () => page.getByRole('button', { name: 'Good response' }).first().getAttribute('aria-pressed'), + { timeout: 10_000 }, + ).toBe('false') + await expect.poll(() => page.getByText(NOTE, { exact: true }).count(), { timeout: 10_000 }).toBe(0) + }, 90_000) + + it.skipIf(MODE === 'record')('kept the console clean', () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 9d9ea8ab0e..b7da854448 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -33,6 +33,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 42197364ae..7ab633e583 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -48,6 +48,10 @@ - paragraph: CORDIS_UI_DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/feedback-command/ack.expected.md b/apps/web/tests/snapshots/feedback-command/ack.expected.md index 89d40acb3b..b5b308a22e 100644 --- a/apps/web/tests/snapshots/feedback-command/ack.expected.md +++ b/apps/web/tests/snapshots/feedback-command/ack.expected.md @@ -20,6 +20,10 @@ - paragraph: LIGHTHOUSE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index d360f936bd..8a2268f24d 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -28,6 +28,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md index c0ece71be8..be77ef8ee9 100644 --- a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md +++ b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md @@ -77,6 +77,10 @@ - paragraph: 这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。 - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s @@ -186,6 +190,10 @@ - text: )的结构,或者其他格式的输出(比如带文件大小的树形图),随时告诉我。 - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - tooltip "Branch into a new conversation" diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 19283ae51d..d4c71faa15 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -20,6 +20,10 @@ - paragraph: LIGHTHOUSE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 7bb61c5b27..88463747a0 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -22,6 +22,10 @@ - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md index dd566c17d5..06c084f747 100644 --- a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -35,6 +35,10 @@ - paragraph: CJK_STRONG_DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index 21e84e000d..319bc6b78b 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -14,6 +14,10 @@ - paragraph: REMOTE_IMAGE_DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md index 221294ad6b..ee727ab171 100644 --- a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -26,6 +26,10 @@ - paragraph: INLINE_CODE_LINK_DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} diff --git a/apps/web/tests/snapshots/math-rendering/ui.expected.md b/apps/web/tests/snapshots/math-rendering/ui.expected.md index 4880d108e0..9bbef6740e 100644 --- a/apps/web/tests/snapshots/math-rendering/ui.expected.md +++ b/apps/web/tests/snapshots/math-rendering/ui.expected.md @@ -30,6 +30,10 @@ - paragraph: MATH_RENDERING_DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 38430d636e..7876ec8577 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -15,6 +15,10 @@ - paragraph: I will read both files before answering. - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation" [disabled]: - img - text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s @@ -38,6 +42,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index 23664b4a1d..d6d48515a2 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -33,6 +33,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index d15b2af3a1..a0a90f9746 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -28,6 +28,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index 3aa9e2d738..dbe8db9c55 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -28,6 +28,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md index 6928b95777..3e48c3f679 100644 --- a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md @@ -28,6 +28,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index a30ae29e1e..bfbf1c09a2 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -28,6 +28,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md index 15ddf45a0d..24a3fa942f 100644 --- a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md +++ b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md @@ -31,6 +31,10 @@ - paragraph: DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{date}} {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md index 1f1cd0ea18..26af0930a3 100644 --- a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md @@ -20,6 +20,10 @@ - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index b20e590686..72a4bd8d8f 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -30,6 +30,10 @@ - paragraph: "Got it: BANANA and ORANGE." - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 93c311cce4..bf4fe45dad 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -31,6 +31,10 @@ - paragraph: Great, let's move forward. BANANA! - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 15ceebba6b..a42437cead 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -25,6 +25,10 @@ - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s Now give the same explanation to a human reader. {{clock}} @@ -37,6 +41,10 @@ - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 9e86cdcf2c..44e8382035 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -20,6 +20,10 @@ - paragraph: SEARCH_DONE - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f96a1f8dd1..fa4aae0ce9 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -53,6 +53,7 @@ "tests/cordis-tool-round.e2e.ts", "tests/web-search-round.e2e.ts", "tests/message-actions.e2e.ts", + "tests/message-feedback.e2e.ts", "tests/markdown-images.e2e.ts", "tests/math-rendering.e2e.ts", "tests/markdown-cjk-strong.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 098c91c804..b246558d27 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 911255077833354351b08bd2800f2116510ca3c0 -config-catalog.zh.md: d3141ab389cb1b8f60b88d504e2598ab1938decc +config-catalog.md: a730801fd6c1307dd5ac1b2281f7052630855f41 +config-catalog.zh.md: 0c379a1f4872bf8528538c401791797f58f31d75 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9112550778..a730801fd6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2740,6 +2740,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-deliverables` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-feedback` ([`packages/client/ui-feedback/src/index.ts`](../packages/client/ui-feedback/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model` ([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d3141ab389..0c379a1f48 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2741,6 +2741,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-command`([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation`([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-deliverables`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-feedback`([`packages/client/ui-feedback/src/index.ts`](../packages/client/ui-feedback/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal`([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout`([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model`([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index f14bf8cea2..96a8e6f4f6 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 59e22a8b82a210dd66f6e2186f0b827a541e00cc -module-graph.zh.md: 00a433ebaeabba4ce0e39919c3e0fe817608e718 +module-graph.md: 6072f8b6851fef92193600d4c84c0d522033447b +module-graph.zh.md: 0c6acac25c5755478dbca6732cd3b6cb84359939 diff --git a/docs/module-graph.md b/docs/module-graph.md index 59e22a8b82..6072f8b685 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -152,6 +152,7 @@ flowchart TD pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] + pkg_client_ui_feedback["client-ui-feedback"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_model["client-ui-model"] @@ -701,6 +702,7 @@ flowchart TD pkg_api_remotes --> pkg_agent pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_invariants + pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_typert_registry @@ -1150,6 +1152,14 @@ flowchart TD pkg_client_ui_deliverables --> pkg_client_ui_conversation pkg_client_ui_deliverables --> pkg_client_ui_slots pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_feedback --> pkg_api_remotes + pkg_client_ui_feedback --> pkg_client_locale + pkg_client_ui_feedback --> pkg_client_runtime + pkg_client_ui_feedback --> pkg_client_ui_conversation + pkg_client_ui_feedback --> pkg_client_ui_primitives + pkg_client_ui_feedback --> pkg_client_ui_slots + pkg_client_ui_feedback --> pkg_invariants + pkg_client_ui_feedback --> pkg_message_feedback pkg_client_ui_goal --> pkg_api_remotes pkg_client_ui_goal --> pkg_client_locale pkg_client_ui_goal --> pkg_client_runtime @@ -1390,7 +1400,7 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | @@ -1464,6 +1474,7 @@ flowchart TD | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-feedback`](../packages/client/ui-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 00a433ebae..0c6acac25c 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -154,6 +154,7 @@ flowchart TD pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_deliverables["client-ui-deliverables"] + pkg_client_ui_feedback["client-ui-feedback"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_model["client-ui-model"] @@ -703,6 +704,7 @@ flowchart TD pkg_api_remotes --> pkg_agent pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_invariants + pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_typert_registry @@ -1152,6 +1154,14 @@ flowchart TD pkg_client_ui_deliverables --> pkg_client_ui_conversation pkg_client_ui_deliverables --> pkg_client_ui_slots pkg_client_ui_deliverables --> pkg_invariants + pkg_client_ui_feedback --> pkg_api_remotes + pkg_client_ui_feedback --> pkg_client_locale + pkg_client_ui_feedback --> pkg_client_runtime + pkg_client_ui_feedback --> pkg_client_ui_conversation + pkg_client_ui_feedback --> pkg_client_ui_primitives + pkg_client_ui_feedback --> pkg_client_ui_slots + pkg_client_ui_feedback --> pkg_invariants + pkg_client_ui_feedback --> pkg_message_feedback pkg_client_ui_goal --> pkg_api_remotes pkg_client_ui_goal --> pkg_client_locale pkg_client_ui_goal --> pkg_client_runtime @@ -1392,7 +1402,7 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | @@ -1466,6 +1476,7 @@ flowchart TD | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-feedback`](../packages/client/ui-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | diff --git a/docs/subsystems/feedback.i18n.yaml b/docs/subsystems/feedback.i18n.yaml index bef441ece2..111182d7ed 100644 --- a/docs/subsystems/feedback.i18n.yaml +++ b/docs/subsystems/feedback.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 docs/subsystems/feedback.md -feedback.md: a0daf47d093f3efb643950c0e124a8db0734fde4 -feedback.zh.md: 1163d30a6af5f818ab3be8ff754de20656e7e743 +feedback.md: 03a14b40968ab27b6321dc82bd12e8af57ddf5f3 +feedback.zh.md: 68da42322dad515ac185296da66401c522fac93c diff --git a/docs/subsystems/feedback.md b/docs/subsystems/feedback.md index a0daf47d09..03a14b4096 100644 --- a/docs/subsystems/feedback.md +++ b/docs/subsystems/feedback.md @@ -201,15 +201,25 @@ The service stores whole Session rows in the `message_feedback` storage domain t Plugin disposal closes mutation admission, drains accepted per-Session queue work, and then closes the storage domain. +## Web surface + +[`@deepseek-ai/dsh-client-ui-feedback`](../../packages/client/ui-feedback) is the browser consumer. `@deepseek-ai/dsh-api-remotes` mounts the generated `messageFeedback` contribution, so the plugin calls `ctx.remote.messageFeedback` and never touches the transport. + +The controls are the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` list slot, which `ui-conversation` declares and renders inside the finalized assistant message's IconActions row. Reaching that render site required one plumbing change: `AssistantMessageNode` now carries the optional `messageId` from the `assistant/message` event. The field is absent on interruption-frozen partials, and the render site skips the slot when it is absent, so only messages the Host accepts as feedback targets present controls. + +One `FeedbackController` per Session backs every message control in that Session: a single `list` read seeds the whole transcript, deferred to first hover or focus rather than fired on mount. Each mutation sends the version that controller last observed as `ifVersion`; a `version-conflict` reply carries the authoritative item, so the controller reconciles from the reply instead of refetching. Mutations serialize per Session so a queued operation compares against the committed version. A `connection/reset` refreshes only Sessions already read. + ## Boundaries and limitations -- The client Remote aggregate mount and UI consumer are separately owned and deferred. - The mutation queue is process-local. Storage-domain has no cross-process conditional write, so multiple Host writers to one storage root have no compare-and-swap or lost-update guarantee. - Session persistence has no durable deletion API. The service does not treat `session/disposed` or `host/session-removed` as deletion and therefore performs no fake cascade; orphan sidecar rows may remain after out-of-band log removal. - A request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization. - Cold requests scan the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. One Session row also has no item-count or aggregate-byte cap; `maxNoteBytes` bounds only each note until a concrete consumer owns a row policy. - Header identity detects a reused id only when `{createdAt, cwd}` differs; a cloned log retaining the same header identity is indistinguishable by this contract. - The Host contract records no authenticated actor or audit identity and therefore assumes a trusted caller boundary. +- The Web controls appear in the chat view only. The trajectory and waterfall views render no feedback entry even though their assistant nodes carry the same `messageId`. +- The sidecar publishes no live frames, so a second tab's rating becomes visible on reconnect or on the next conflict reply rather than immediately. +- The note editor does not pre-check `maxNoteBytes`; an oversized note fails on save with `note-too-large` rather than while typing. diff --git a/docs/subsystems/feedback.zh.md b/docs/subsystems/feedback.zh.md index 1163d30a6a..68da42322d 100644 --- a/docs/subsystems/feedback.zh.md +++ b/docs/subsystems/feedback.zh.md @@ -201,15 +201,25 @@ type MessageFeedbackDeleteResult = Plugin disposal 会先关闭变更接纳,排空已进入各 Session 队列的工作,然后才关闭 storage domain。 +## Web 界面 + +[`@deepseek-ai/dsh-client-ui-feedback`](../../packages/client/ui-feedback) 是浏览器侧消费方。`@deepseek-ai/dsh-api-remotes` 挂载生成的 `messageFeedback` 贡献,因此该插件调用 `ctx.remote.messageFeedback`,不接触传输层。 + +控件是 `conversation.chat.assistant-actions` list slot 的 `feedback` 条目(order 10),该 slot 由 `ui-conversation` 声明,并渲染在已定稿助手消息的 IconActions 行内。为抵达该渲染点需要一处管道改动:`AssistantMessageNode` 现在携带来自 `assistant/message` 事件的可选 `messageId`。被中断冻结的部分输出没有该字段,渲染点在字段缺失时跳过该 slot,因此只有 Host 认可为反馈目标的消息才会出现控件。 + +每个 Session 一个 `FeedbackController`,支撑该 Session 内所有消息的控件:一次 `list` 读取即填充整段对话,且延迟到首次 hover 或 focus 才发起,而非挂载时触发。每次变更把该 controller 最后观察到的版本作为 `ifVersion` 发送;`version-conflict` 响应携带权威条目,controller 据此对账而不重新拉取。变更按 Session 串行,排队操作与已提交版本比较。`connection/reset` 只刷新已读取过的 Session。 + ## 边界与限制 -- 客户端 Remote 聚合挂载与 UI 消费方由各自边界负责并保持延后。 - 变更队列仅在进程内生效。storage-domain 没有跨进程条件写,因此多个 Host 写入同一存储根目录时,不提供 compare-and-swap 或防止丢失更新的保证。 - Session persistence 没有持久删除接口。服务不把 `session/disposed` 或 `host/session-removed` 当作删除,因此不伪造级联;在带外移除日志后,孤儿伴随记录可能继续存在。 - 请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。 - 由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。单个 Session 行也没有条目数或聚合字节上限;在具体消费方拥有行策略之前,`maxNoteBytes` 只限制每条备注。 - 只有 `{createdAt, cwd}` 不同时,header 身份才能识别复用的 id;本契约无法区分保留相同 header 身份的克隆日志。 - Host 契约不记录已认证的 actor 或审计身份,因此假设调用方边界可信。 +- Web 控件只出现在对话视图。trajectory 与 waterfall 视图不渲染反馈条目,尽管它们的助手节点携带相同的 `messageId`。 +- 该 sidecar 不发布实时帧,因此另一个标签页的评分要等到重连或下一次冲突响应才可见,不会立即出现。 +- 备注编辑器不预先校验 `maxNoteBytes`;超长备注在保存时以 `note-too-large` 失败,而不是在输入过程中。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 241a86ff9f..66f5735a3f 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", @@ -65,6 +66,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index ce67fb05d7..88eed60909 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -2,10 +2,12 @@ import type { Context } from '@deepseek-ai/cordis' import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' export type {} from '@deepseek-ai/dsh-goal/remote' +export type {} from '@deepseek-ai/dsh-message-feedback/remote' declare module '@deepseek-ai/cordis' { interface Context { @@ -23,5 +25,11 @@ export const inject = ['remote'] * @returns disposer after every selected Remote namespace is ready. */ export async function apply(ctx: Context): Promise<() => Promise> { - return await ctx.remote.$mount(goalsRemote) + const mounted = [ + await ctx.remote.$mount(goalsRemote), + await ctx.remote.$mount(messageFeedbackRemote), + ] + return async () => { + for (const dispose of mounted.reverse()) await dispose() + } } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index bc26c0b13f..c8f6914c7c 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -15,6 +15,9 @@ { "path": "../../goal/goal" }, + { + "path": "../../feedback/message-feedback" + }, { "path": "../../typert/type-meta" } diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 1b34c11d1b..4a4ea73add 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -215,6 +215,11 @@ - id: ui-goal name: '@deepseek-ai/dsh-client-ui-goal' + # Per-message feedback: Like/Dislike plus an optional note in the + # assistant-message action strip, over the messageFeedback Remote. + - id: ui-feedback + name: '@deepseek-ai/dsh-client-ui-feedback' + # Model selection: the /model popupSelect + composer seat over session.models. - id: ui-model name: '@deepseek-ai/dsh-client-ui-model' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 95e7428f0a..6d361bd30f 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", + "@deepseek-ai/dsh-client-ui-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 4397013dab..78652ce342 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -96,6 +96,12 @@ export interface AssistantTiming { export interface AssistantMessageNode { kind: 'assistant' seq: number + /** + * Stable identity of the finalized model output, carried from the + * `assistant/message` event. Absent on interruption-frozen partials: those + * were never finalized, so they address no durable message. + */ + messageId?: MessageId /** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */ time: number turn: number diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index d70912e346..7d817d6ab5 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -1,7 +1,7 @@ // Shared IconActions chrome for user and assistant messages: copy // live, optional branch wiring, and an optional date-aware clock. -import { useCallback, useEffect, useId, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react' import { IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -29,6 +29,11 @@ export interface MessageIconActionsProps { branchUnavailable?: boolean | undefined /** Parent layout class composed onto the actions row. */ className?: string | undefined + /** + * Slot-rendered actions owned by independent plugins, placed between the + * built-in copy and branch controls. + */ + extraActions?: ReactNode /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -39,7 +44,8 @@ export interface MessageIconActionsProps { * @returns The actions row element. */ export function MessageIconActions({ - text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, t, + text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, + extraActions, t, }: MessageIconActionsProps) { const day = useCalendarDay() const reasonId = useId() @@ -109,6 +115,7 @@ export function MessageIconActions({ {copied ? : } + {extraActions} {onBranch !== undefined && ( {/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */} diff --git a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx index 444389e6ea..d4d27570f9 100644 --- a/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-conversation/src/client/chat/TurnTailNodeView.tsx @@ -5,11 +5,12 @@ import { MessageIconActions } from './MessageIconActions.tsx' import { assistantText } from './turn-assistant.ts' import css from './TurnTailNodeView.module.css' -type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<'conversation.chat.turnTail'> +type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> + & PropsRenderSlots<'conversation.chat.turnTail' | 'conversation.chat.assistant-actions'> /** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */ export const TurnTailNodeView = memo(function TurnTailNodeView({ - node, openFile, forkAt, renderSlotChain, t, useSession, + node, openFile, forkAt, renderSlot, renderSlotChain, t, useSession, }: TurnTailNodeViewProps) { const data = node.data const hasLaterChatNode = useSession(snapshot => @@ -25,6 +26,12 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ const runMs = turn.start === undefined || turn.end === undefined ? undefined : Math.max(0, turn.end.time - turn.start.time) + // Interruption-frozen partials carry no messageId, so they address no + // durable message and contribute no per-message actions. + const messageId = closing.finalNode.messageId + const assistantActions = messageId === undefined + ? null + : renderSlot('conversation.chat.assistant-actions', { messageId }) return (
{tail} @@ -38,6 +45,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ onBranch={() => { forkAt(closing.finalNode.seq) }} branchUnavailable={data.branchUnavailable || hasLaterChatNode} className={css.actions} + extraActions={assistantActions} t={t} />
diff --git a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts index 78aa36d136..687f04b252 100644 --- a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts @@ -39,7 +39,10 @@ export function registerChatNodeRenderers(ctx: Context): void { name: 'conversation.chat.node', key: 'turn-tail', locale: NS, - children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, + children: { + 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, + 'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' }, + }, }, TurnTailNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'unknown', locale: NS }, UnknownNodeView)) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 602e54d5d3..83f298a695 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -11,6 +11,7 @@ import type { TurnLocation, WorkspaceId, } from '@deepseek-ai/dsh-client-runtime/client' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import type { MessageId } from '@deepseek-ai/dsh-client-connection/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerBlock } from '../input/blocks.ts' import type { @@ -77,6 +78,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * only to return null; an all-declined chain renders nothing. */ 'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps } + /** + * Action strip attached to one finalized assistant message, rendered + * inside that message's IconActions row. The chat entry owns the render + * site and passes the addressed message identity; contributors add + * per-message actions without importing the conversation implementation. + * Entries render by ascending `order`. + */ + 'conversation.chat.assistant-actions': { + kind: 'list' + scope: 'session' + owner: AssistantActionOwnerProps + } /** Selected Tool call output inside the details panel. */ 'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps } /** @@ -253,6 +266,16 @@ export interface TurnTailOwnerProps { openFile: (path: string) => void } +/** + * Owner currency of the assistant-message action strip: the durable identity + * of the one finalized message the contributed actions address. Only finalized + * messages reach this slot, so the id is always present. + */ +export interface AssistantActionOwnerProps { + /** Stable identity carried from the `assistant/message` event. */ + messageId: MessageId +} + /** Hook constrained to business data published on the current Chat Node's Turn. */ export type UseChatNodeTurnData = >( key: Key, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts index 641a0287e4..880bbafa44 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -152,6 +152,7 @@ function finalNode( return { kind: 'assistant', seq: event.seq, + messageId: event.data.message.id, time: event.time, turn: state.turn, step: state.step, diff --git a/packages/client/ui-feedback/README.i18n.yaml b/packages/client/ui-feedback/README.i18n.yaml new file mode 100644 index 0000000000..1f9cdea206 --- /dev/null +++ b/packages/client/ui-feedback/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 packages/client/ui-feedback/README.md +README.md: 2f347a22427ce61ea1093434273ba9b4ba759852 +README.zh.md: 2aea4cd2f4c29312f2c771094b7831171bff760f diff --git a/packages/client/ui-feedback/README.md b/packages/client/ui-feedback/README.md new file mode 100644 index 0000000000..2f347a2242 --- /dev/null +++ b/packages/client/ui-feedback/README.md @@ -0,0 +1,25 @@ +# @deepseek-ai/dsh-client-ui-feedback + +English | [中文](README.zh.md) + +Per-message feedback plugin, browser half: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` strip. The strip is declared by `ui-conversation` and rendered inside the finalized assistant message's IconActions row, between copy and branch, so the controls inherit that row's chrome and hover behavior. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. + +One `FeedbackController` per Session backs every message control in that Session, so a single `messageFeedback.list` read seeds the whole transcript. The read is deferred to the first hover or focus rather than fired on mount, because the controls mount once per settled message in the visible history. + +Mutations go through `ctx.remote.messageFeedback`; the Host owns per-item compare-and-set. Every `put` and `delete` carries the `version` this controller last observed, and a `version-conflict` reply carries the authoritative item, so a lost race reconciles from the reply itself instead of refetching the Session. Mutations serialize per Session, so a queued operation always compares against the committed version. Re-clicking the recorded rating retracts the feedback; switching sides carries the existing note forward. + +The `/client` exports are the plugin body (`apply`/`inject`), the `FeedbackActions` component, the `FeedbackController` class, and the injected face types. + +## Model Experience + +None, as feedback is a sidecar that never enters the append-only Session log, the model context, or telemetry; no rating or note is ever visible to the model. + +#### KV Cache effect + +None; no feedback mutation touches the history tail. + +## Known Limitations and Deferred Work + +- **Note size is a Host policy** — the deployment configures `maxNoteBytes` (8192 in the Web bundle) and the Host rejects an oversized note with `note-too-large`. The editor does not pre-check the limit, so an oversized note fails on save rather than while typing. +- **No cross-tab push** — a second tab's rating becomes visible on reconnect or on the next conflict reply, not immediately; the sidecar publishes no live frames. +- **Chat view only** — the trajectory and waterfall views render no feedback controls even though their assistant nodes now carry the same `messageId`. diff --git a/packages/client/ui-feedback/README.zh.md b/packages/client/ui-feedback/README.zh.md new file mode 100644 index 0000000000..2aea4cd2f4 --- /dev/null +++ b/packages/client/ui-feedback/README.zh.md @@ -0,0 +1,25 @@ +# @deepseek-ai/dsh-client-ui-feedback + +[English](README.md) | 中文 + +单条消息反馈插件的浏览器侧:一对 Like/Dislike 按钮加一个可选备注,作为 `conversation.chat.assistant-actions` 条带的 `feedback` 条目(order 10)贡献。该条带由 `ui-conversation` 声明,渲染在已定稿助手消息的 IconActions 行内、复制与分支之间,因此控件沿用该行的样式与 hover 行为。只有已定稿的消息能到达这个 slot——被中断冻结的部分输出不带 `messageId`,因此也没有反馈控件。 + +每个 Session 一个 `FeedbackController`,支撑该 Session 内所有消息的控件,因此一次 `messageFeedback.list` 读取即可填充整段对话。该读取延迟到首次 hover 或 focus 才发起,而不是在挂载时触发,因为可见历史中每条已结束的消息都会挂载一次控件。 + +变更通过 `ctx.remote.messageFeedback` 提交,按条目的 compare-and-set 由 Host 负责。每次 `put` 和 `delete` 都携带本 controller 最后观察到的 `version`;`version-conflict` 响应会带回权威条目,因此竞争失败时直接用该响应对账,无需重新拉取整个 Session。变更按 Session 串行,排队中的操作总是与已提交的版本比较。再次点击已记录的评分会撤回反馈;切换到另一侧会保留已有备注。 + +`/client` 导出插件本体(`apply`/`inject`)、`FeedbackActions` 组件、`FeedbackController` 类以及注入面类型。 + +## 模型体验 + +无。反馈是 sidecar,不进入 append-only 的 Session 日志、模型上下文或遥测;任何评分与备注对模型都不可见。 + +#### KV Cache 影响 + +无;任何反馈变更都不触碰历史尾部。 + +## 已知限制与暂缓事项 + +- **备注大小是 Host 策略** —— 部署方配置 `maxNoteBytes`(Web bundle 中为 8192),超长备注由 Host 以 `note-too-large` 拒绝。编辑器不预先校验该上限,因此超长备注在保存时才失败,而不是在输入过程中。 +- **无跨标签页推送** —— 另一个标签页的评分要等到重连或下一次冲突响应才可见,不会立即出现;该 sidecar 不发布实时帧。 +- **仅限对话视图** —— trajectory 与 waterfall 视图不渲染反馈控件,尽管它们的助手节点现在也带有相同的 `messageId`。 diff --git a/packages/client/ui-feedback/package.json b/packages/client/ui-feedback/package.json new file mode 100644 index 0000000000..d4cf2e70eb --- /dev/null +++ b/packages/client/ui-feedback/package.json @@ -0,0 +1,82 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-feedback", + "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-feedback" + }, + "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" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-feedback/src/client/FeedbackActions.module.css b/packages/client/ui-feedback/src/client/FeedbackActions.module.css new file mode 100644 index 0000000000..8f554f2a3b --- /dev/null +++ b/packages/client/ui-feedback/src/client/FeedbackActions.module.css @@ -0,0 +1,108 @@ +/* Per-message feedback controls. The rating buttons mirror the shared message + IconActions chrome so the strip reads as one row; the note editor is an + inline expansion anchored to the same row. */ + +.action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 6px; + border: none; + border-radius: 28px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.action:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + +.action:disabled { + cursor: default; + opacity: 0.4; +} + +/* A recorded rating stays legible without hover, so the signal survives a + pointer leaving the row. */ +.action[data-active] { + color: var(--dsw-alias-label-primary); +} + +.noteOpen { + max-width: 220px; + overflow: hidden; + padding: 0 8px; + border: none; + border-radius: 14px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 28px; + white-space: nowrap; + text-overflow: ellipsis; + cursor: pointer; +} + +.noteOpen:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + +.noteEditor { + display: inline-flex; + align-items: flex-start; + gap: 6px; +} + +.noteInput { + width: 260px; + padding: 6px 8px; + border: 1px solid var(--dsw-alias-border-secondary); + border-radius: 8px; + background: var(--dsw-alias-bg-primary); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; + resize: vertical; +} + +.noteSave, +.noteCancel { + height: 28px; + padding: 0 10px; + border: none; + border-radius: 14px; + font-size: 13px; + cursor: pointer; +} + +.noteSave { + background: var(--dsw-alias-interactive-bg-primary); + color: var(--dsw-alias-label-inverse); +} + +.noteSave:disabled { + cursor: default; + opacity: 0.4; +} + +.noteCancel { + background: transparent; + color: var(--dsw-alias-label-tertiary); +} + +.noteCancel:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + +.failure { + padding-left: 4px; + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 28px; +} diff --git a/packages/client/ui-feedback/src/client/FeedbackActions.tsx b/packages/client/ui-feedback/src/client/FeedbackActions.tsx new file mode 100644 index 0000000000..5ecb1b466a --- /dev/null +++ b/packages/client/ui-feedback/src/client/FeedbackActions.tsx @@ -0,0 +1,140 @@ +/** + * Per-message feedback controls: a Like/Dislike pair plus an optional note. + * Rendered inside the assistant message's IconActions row, so the buttons + * reuse that row's chrome and sit between copy and branch. + * @module @deepseek-ai/dsh-client-ui-feedback/client/FeedbackActions + */ + +import { useCallback, useEffect, useRef, useState } from 'react' +import { + IconDislikeOutline16, IconLikeOutline16, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types' +import type { FeedbackActionProps } from './slots.ts' +import css from './FeedbackActions.module.css' + +/** + * One message's feedback controls. + * @param props - the owner's message identity, the injected verbs, and the + * shared feedback hook. + * @returns the rating buttons, plus the note editor while it is open. + */ +export function FeedbackActions({ messageId, ensure, rate, clear, useFeedback, t }: FeedbackActionProps) { + const item = useFeedback(view => view.items.get(messageId)) + const rating = item?.rating + const [noteOpen, setNoteOpen] = useState(false) + const [draft, setDraft] = useState('') + const [pending, setPending] = useState(false) + const [failure, setFailure] = useState(null) + // The controls mount for every settled message in the transcript, so the + // Session's feedback is read once on first hover/focus rather than on mount. + const seeded = useRef(false) + const seed = useCallback(() => { + if (seeded.current) return + seeded.current = true + void ensure() + }, [ensure]) + + const alive = useRef(true) + useEffect(() => () => { alive.current = false }, []) + + const settle = useCallback((result: { ok: boolean; error?: { code: string } }) => { + if (!alive.current) return + setPending(false) + if (result.ok) { + setFailure(null) + return + } + setFailure(result.error?.code === 'version-conflict' ? t('error.conflict') : t('error.generic')) + }, [t]) + + const onRate = useCallback((next: MessageFeedbackRating) => { + setPending(true) + setFailure(null) + // Re-clicking the active rating retracts it; the note goes with it. + if (rating === next) { + setNoteOpen(false) + void clear(messageId).then(settle) + return + } + void rate(messageId, next, item?.note).then(settle) + }, [clear, item?.note, messageId, rate, rating, settle]) + + const onSaveNote = useCallback(() => { + if (rating === undefined) return + const trimmed = draft.trim() + setPending(true) + setFailure(null) + void rate(messageId, rating, trimmed.length === 0 ? undefined : trimmed).then((result) => { + settle(result) + if (result.ok && alive.current) setNoteOpen(false) + }) + }, [draft, messageId, rate, rating, settle]) + + const openNote = useCallback(() => { + setDraft(item?.note ?? '') + setNoteOpen(true) + }, [item?.note]) + + const likeLabel = rating === 'positive' ? t('action.likeActive') : t('action.like') + const dislikeLabel = rating === 'negative' ? t('action.dislikeActive') : t('action.dislike') + + return ( + <> + + + + + + + {rating !== undefined && !noteOpen && ( + + )} + {noteOpen && ( + +