Kaynağa Gözat

Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
#	.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
#	.agents/notes/implemented/feature/2026-07-30-search-render-card.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-30-search-render-card.md
#	.agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md
#	.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-30-web-read-card.md
#	.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md
#	.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-30-web-result-card.md
#	.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md
_Kerman 1 ay önce
ebeveyn
işleme
d5527da905
34 değiştirilmiş dosya ile 598 ekleme ve 103 silme
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml
  2. 52 0
      .agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md
  3. 52 0
      .agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml
  5. 2 2
      .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
  6. 2 2
      .agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-07-30-search-render-card.i18n.yaml
  8. 3 3
      .agents/notes/implemented/feature/2026-07-30-search-render-card.md
  9. 3 3
      .agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml
  11. 2 3
      .agents/notes/implemented/feature/2026-07-30-web-read-card.md
  12. 2 3
      .agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md
  13. 2 2
      .agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml
  14. 3 3
      .agents/notes/implemented/feature/2026-07-30-web-result-card.md
  15. 3 3
      .agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md
  16. 2 2
      .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml
  17. 4 4
      .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md
  18. 4 4
      .agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md
  19. 2 2
      apps/cli/README.i18n.yaml
  20. 2 0
      apps/cli/README.md
  21. 2 0
      apps/cli/README.zh.md
  22. 10 8
      apps/cli/config/base.cordis.yml
  23. 12 19
      apps/cli/src/headless.ts
  24. 58 0
      apps/cli/src/process-shutdown.ts
  25. 4 8
      apps/cli/src/web.ts
  26. 18 0
      apps/cli/tests/fixtures/never-dispose.mjs
  27. 121 0
      apps/cli/tests/headless-shutdown.e2e.ts
  28. 131 0
      apps/cli/tests/process-shutdown.spec.ts
  29. 5 4
      docs/config-catalog.md
  30. 2 2
      packages/telemetry/session-telemetry-otel/README.i18n.yaml
  31. 2 1
      packages/telemetry/session-telemetry-otel/README.md
  32. 2 1
      packages/telemetry/session-telemetry-otel/README.zh.md
  33. 45 18
      packages/telemetry/session-telemetry-otel/src/index.ts
  34. 34 0
      packages/telemetry/session-telemetry-otel/tests/otel.spec.ts

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.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-03-cli-signal-shutdown-escalation.md
+2026-08-03-cli-signal-shutdown-escalation.md: 2746b5784baad0f3b14258280cd56a621db07c15
+2026-08-03-cli-signal-shutdown-escalation.zh.md: 0bda83327d4cc8fe2edb61f8145a89138610901e

+ 52 - 0
.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md

@@ -0,0 +1,52 @@
+# Agent Note: Bounded, escalating signal shutdown for Web and headless
+
+Status: implemented
+
+English | [中文](2026-08-03-cli-signal-shutdown-escalation.zh.md)
+
+## Problem
+
+The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and `dsh -p` so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound.
+
+A user then reproduced `dsh -p` hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts.
+
+The latch then turned that telemetry defect into an unkillable CLI: normal completion was already awaiting the single-shot root disposal; the first SIGINT joined the same pending disposal and set the signal latch; later SIGINTs returned at the latch, so the process had no remaining escape. A signal received before normal completion had the same unbounded wait. Web used the same latch shape.
+
+Telemetry's own timeouts cannot prove that the whole plugin tree settles. Any current or future disposer can wedge, and the process boundary must preserve both a graceful first attempt and a user-controlled way out.
+
+## Decision
+
+The fix has two ownership layers. The OTel backend adds `shutdownTimeoutMillis` (default and shipped value: three seconds) around the SDK provider's complete shutdown Promise. Crossing it rejects into the telemetry coordinator's existing contained-failure path, allowing the Cordis tree to finish disposal; pending records may be lost because OTel exposes no cancellation for the transport Promise.
+
+Web and headless share `createProcessShutdown`, one process-level controller around root disposal:
+
+- Normal shutdown calls coalesce onto one disposal and retain the first requested exit code; they never escalate one another.
+- The first signal starts the same graceful disposal and a referenced five-second exit backstop. Disposal success or failure exits once; neither can cancel the process exit.
+- A signal received while shutdown is pending forces immediate exit with that signal path's code. This includes the first `Ctrl+C` after headless normal completion has already entered disposal, and a second signal after a signal initiated the drain.
+- The five-second bound is a process-safety invariant, not a deployment tunable. It is long enough for the telemetry deployment's ordinary drain ceiling while still bounding any wedged disposer at the launcher boundary.
+
+Headless preserves exit 0 for a completed turn, exit 1 for another turn-end reason or API business error, 130 for SIGINT, and 143 for SIGTERM. Web preserves its existing SIGTERM exit 0 and SIGINT exit 130 behavior.
+
+This supersedes the [telemetry deployment Note's](../feature/2026-07-31-web-telemetry-default-mount.md) assumption that SDK exporter/processor timeouts bound complete provider shutdown, and its earlier decision to defer a process-level backstop. The backend owns its export loss/latency policy and closes the known SDK `forceFlush()` gap; the launcher owns the outer guarantee that no plugin can trap the process indefinitely.
+
+## Alternatives considered
+
+**Bound only the telemetry backend's `shutdown()`.** Insufficient because it protects the known OTel wait but cannot protect the launcher from another plugin's disposer.
+
+**Restore Node's default immediate signal exit.** Rejected because a healthy first signal should still flush telemetry and release other resources. Immediate exit is the explicit escalation path, not the default.
+
+**Add only the five-second timeout.** Rejected because a user pressing `Ctrl+C` again is asking to stop waiting now. Swallowing that intent for the rest of the grace period recreates the reported behavior at a shorter duration.
+
+## Consequences
+
+A healthy exit still disposes the complete Cordis tree. The known telemetry wait releases after at most three seconds; any other wedged exit lasts at most five seconds without further input, and a repeated signal ends it immediately. Forced or deadline-bounded exit can interrupt telemetry export or remaining cleanup, which is intentional only after the graceful contract has failed or the user has explicitly escalated.
+
+The controller is launcher infrastructure rather than a Cordis plugin: it makes no claim that disposal completed, and it does not weaken the lifecycle rule that ordinary disposers must reach quiescence.
+
+## Testing
+
+`apps/cli/tests/process-shutdown.spec.ts` pins resolved and rejected disposal, the five-second backstop, normal-call coalescing, a signal interrupting normal disposal, and second-signal escalation.
+
+`apps/cli/tests/headless-shutdown.e2e.ts` boots the real shipped Web/headless Loader tree in a PTY with a test-only plugin whose disposer announces entry and never settles. The test sends SIGINT after the observation URL, waits for proof that disposal started, sends SIGINT again, and requires exit 130. The source/artifact launch resolver keeps the same regression on both execution planes. This PTY case covers the user-visible process state; no model-output snapshot changes.
+
+`packages/telemetry/session-telemetry-otel/tests/otel.spec.ts` holds a real OTLP request open after timer export begins and pins that Cordis disposal returns at `shutdownTimeoutMillis`, despite the SDK's `forceFlush()` remaining pending. The collector is then released so the still-observed provider Promise settles cleanly.

+ 52 - 0
.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md

@@ -0,0 +1,52 @@
+# Agent Note:Web 与 headless 的有界信号关闭和重复信号强制退出
+
+状态:已实现
+
+[English](2026-08-03-cli-signal-shutdown-escalation.md) | 中文
+
+## 问题
+
+默认挂载遥测后,`dsh web` 与 `dsh -p` 新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。
+
+随后有用户复现,`dsh -p` 在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测,而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promise;OTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此,代理/沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。
+
+闩锁随后把这个遥测缺陷变成无法终止的 CLI(命令行界面):正常完成流程已经在等待单次根级 dispose;第一次 SIGINT 会加入同一个待结算的 dispose,并设置信号闩锁;后续 SIGINT 在闩锁处直接返回,因此进程再无退出途径。正常完成之前收到信号时,同样会陷入无界等待。Web 使用的闩锁结构与此相同。
+
+遥测自身的超时无法证明整棵插件树都能结算。任何当前或未来的 disposer 都可能卡死;进程边界既要保留第一次优雅关闭的机会,也必须给用户留下强制退出的途径。
+
+## 决策
+
+修复分为两层归属。OTel 后端围绕 SDK 提供方的完整关闭 Promise 增加 `shutdownTimeoutMillis`(默认值和交付值均为 3 秒)。超过该截止时间时会 reject,并进入遥测协调器现有的失败隔离路径,使 Cordis 插件树能够完成 dispose;由于 OTel 未公开取消传输 Promise 的能力,待处理记录可能丢失。
+
+Web 与 headless 共用 `createProcessShutdown`,它是围绕根级 dispose 建立的进程级控制器:
+
+- 多次正常关闭调用会汇合到同一次 dispose,并保留首次请求的退出码;这些调用不会相互触发强制退出。
+- 第一个信号会启动同一次优雅 dispose,并设置一个带引用的 5 秒退出兜底。dispose 无论成功或失败都会触发且仅触发一次退出;任何一种结果都无法取消进程退出。
+- 关闭待结算期间收到信号时,会立即按该信号路径的退出码强制退出。这既包括 headless 正常完成已经进入 dispose 后收到的第一次 `Ctrl+C`,也包括由信号启动排空后收到的第二个信号。
+- 5 秒上限是进程安全不变式,而不是部署调节项。它足以覆盖遥测部署的常规排空时限,同时仍在启动器边界为任何卡死的 disposer 设置等待上限。
+
+headless 对完成的轮次仍以 0 退出,对其他轮次结束原因或 API 业务错误仍以 1 退出,对 SIGINT 以 130 退出,对 SIGTERM 以 143 退出。Web 保留现有行为:SIGTERM 以 0 退出,SIGINT 以 130 退出。
+
+这项决策取代了[遥测部署 Agent Note](../feature/2026-07-31-web-telemetry-default-mount.md) 中 SDK 导出器/处理器超时能够限制提供方完整关闭流程的假设,也取代了其中暂缓进程级退出兜底的决定。后端负责导出数据丢失与延迟策略,并封住已知的 SDK `forceFlush()` 缺口;启动器负责最外层保证,确保任何插件都无法无限期困住进程。
+
+## 考虑过的替代方案
+
+**只限制遥测后端的 `shutdown()`。** 仍不充分:它能保护已知的 OTel 等待,但无法保护启动器免受其他插件 disposer 的影响。
+
+**恢复 Node 默认的信号即时退出。** 不予采纳:收到第一个信号时,健康流程仍应刷新遥测数据并释放其他资源。即时退出是显式的强制退出路径,而非默认行为。
+
+**只增加 5 秒超时。** 不予采纳:用户再次按下 `Ctrl+C`,就是要求立即停止等待。若在剩余宽限期内继续吞掉这一意图,只是缩短了报告中故障的持续时间,并未解决问题。
+
+## 后果
+
+健康的退出流程仍会对整棵 Cordis 插件树执行 dispose。已知的遥测等待最多会在 3 秒后解除;其他退出流程卡死时,如无进一步输入,最多等待 5 秒,再次收到信号则立即结束进程。强制退出或受截止时间限制的退出可能中断遥测导出或尚未完成的清理工作;只有优雅关闭契约已经失败,或用户明确要求强制退出时,才会有意接受这一结果。
+
+该控制器属于启动器基础设施,而不是 Cordis 插件:它不会声称 dispose 已经完成,也不会削弱普通 disposer 必须达到完全停稳状态的生命周期规则。
+
+## 测试
+
+`apps/cli/tests/process-shutdown.spec.ts` 固定了 dispose 成功与失败、5 秒退出兜底、正常调用汇合、信号中断正常 dispose,以及第二次信号强制退出的行为。
+
+`apps/cli/tests/headless-shutdown.e2e.ts` 在 PTY 中启动真实交付的 Web/headless Loader 插件树,并挂载一个仅用于测试的插件;该插件的 disposer 会声明已经进入清理流程,但永不结算。测试在观察地址出现后发送 SIGINT,等待 dispose 已启动的证据,再次发送 SIGINT,并要求进程以 130 退出。源码/产物启动解析器使两个执行平面都覆盖同一项回归。该 PTY 用例覆盖用户可见的进程状态;模型输出快照没有变化。
+
+`packages/telemetry/session-telemetry-otel/tests/otel.spec.ts` 在定时器导出开始后保持一条真实 OTLP 请求打开,并固定以下行为:即使 SDK 的 `forceFlush()` 仍待结算,Cordis dispose 也会在 `shutdownTimeoutMillis` 到期时返回。随后测试释放 collector,使仍受观察的提供方 Promise 干净结算。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-web-terminal-card.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-28-web-terminal-card.md
-2026-07-28-web-terminal-card.md: ced198c6902b8b6d4df463d7889ec5bfb659891e
-2026-07-28-web-terminal-card.zh.md: 55b75749a0fd603d4e77520d73724ad131f7e300
+2026-07-28-web-terminal-card.md: 0e5f3e2157ebfc4e71aead26c15b6ee91958a5d5
+2026-07-28-web-terminal-card.zh.md: 1285d3fbb46ebd32ff163feac632cd487e8a04f1

Dosya farkı çok büyük olduğundan ihmal edildi
+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md


Dosya farkı çok büyük olduğundan ihmal edildi
+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md


+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-search-render-card.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-search-render-card.md
-2026-07-30-search-render-card.md: 80cce207512a6a9cd1d51f9fcfc3bac8c8e82771
-2026-07-30-search-render-card.zh.md: 6d16313755e533b07e10a75376459ffd875abee4
+2026-07-30-search-render-card.md: 29544cb703f3ab048f4e7702935887ecf05179bf
+2026-07-30-search-render-card.zh.md: e0ae21924e82152ba629ab628476113ad9afe3d8

+ 3 - 3
.agents/notes/implemented/feature/2026-07-30-search-render-card.md

@@ -18,7 +18,7 @@ The discriminant is `shape`, not `kind`, deliberately: the same presentation mod
 
 One view with two shapes rather than two cards, because both tools are the same visual object — a search result — and a web consumer switches on one `card` value, then on `shape` for the row layout. The discriminated `shape` keeps each variant's fields non-optional (a matches view always has `files`, a paths view always has `paths`) instead of a single interface where every shape-specific field is optional.
 
-The view carries **no** result text. Attaching the model-facing `result.content` would only serialize the whole search text a second time into the persisted view: fallback clients already read the raw `tool/result` content. The view is the structured shape only; a UI without a search card falls back to that raw content.
+The view carries **no** result text. An earlier revision attached the model-facing `result.content` to the view; that was a no-op because consumer fallbacks already read the raw `tool/result` content, and it serialized the whole search text a second time into the persisted view. The view is the structured shape only; a UI without a search card falls back to the raw result content.
 
 The card tag is result-time only. A search call stays a `GenericCallView` (`kind: 'search'`): the pending state has no matches or paths to show, so there is nothing a `SearchCallView` would carry that the generic title does not. This is the asymmetry with the terminal card, whose call view carries the command, cwd, and description that exist before execution; a search's structured content exists only after `execute`.
 
@@ -30,7 +30,7 @@ The card tag is result-time only. A search call stays a `GenericCallView` (`kind
 
 The `SearchMeta` member shapes are object-literal `type` aliases, not the `SearchFileMatches`/`SearchLineMatch` interfaces the view exposes, because only a type alias is assignable to the `JsonValue` index signature `presentationMeta` returns; the two are structurally identical, so the projected value still reads back as a `SearchResultView`.
 
-The Web frontend renders the structured `files`/`paths` shape through the [Web search card](2026-07-30-web-search-card.md). This note owns the backend contract and its two producers; clients without that card use the raw result-content fallback.
+A consumer without a dedicated `search` arm falls back to the same generic body and reads the model-facing text from the raw result. Because the search view carries no `content` of its own and grep/glob returned a generic card before this PR, that fallback stays byte-identical to the pre-search-card path. The frontend that renders the structured `files`/`paths` shape is independent of this backend contract and its two producers.
 
 ## Alternatives considered
 
@@ -48,7 +48,7 @@ The Web frontend renders the structured `files`/`paths` shape through the [Web s
 
 `grep` and `glob` now compute `presentationMeta` on every non-nested successful call, a bounded projection over the already-retained matches or paths — the same retention outcome the render consumes, so there is no second retention pass and no doubled search text on the wire. The serialized meta is bounded by `searchMetaMaxBytes`, so a broad search no longer persists an unbounded structured copy into the session log.
 
-A UI without a search card renders the raw `tool/result` content. The web consumer that renders the structured shape reads `truncated`/`total` and the per-file groups; because the view carries only the retained, byte-bounded page, a UI wanting the complete result follows the spill locator in the model-facing text, exactly as the model does.
+A UI without a search card renders the raw `tool/result` content, so no consumer regresses. A consumer that renders the structured shape reads `truncated`/`total` and the per-file groups; because the view carries only the retained, byte-bounded page, a UI wanting the complete result follows the spill locator in the model-facing text, exactly as the model does.
 
 ## Testing
 

+ 3 - 3
.agents/notes/implemented/feature/2026-07-30-search-render-card.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 用一个带两种形状的视图而非两张卡片,因为两个工具是同一个视觉对象 —— 一个搜索结果 —— web 消费方先在一个 `card` 值上分支,再在 `shape` 上分支决定行布局。判别式 `shape` 让每个变体的字段保持非可选(matches 视图总有 `files`,paths 视图总有 `paths`),而不是一个所有形状相关字段都可选的单一接口。
 
-该视图**不**携带结果文本。附上面向模型的 `result.content` 只会把整段搜索文本再次序列化进持久化视图:回退客户端本就读取原始 `tool/result` 内容。视图只承载结构化形状;无 search 卡片的 UI 回退到原始内容。
+该视图**不**携带结果文本。早期版本曾把面向模型的 `result.content` 附到视图上;但消费方的回退路径本就读取原始 `tool/result` 内容,因此这不会产生效果,却会把整段搜索文本又序列化进持久化视图一遍。视图只承载结构化形状;无 search 卡片的 UI 回退到原始结果内容。
 
 卡片标签只在结果时存在。搜索调用保持为 `GenericCallView`(`kind: 'search'`):pending 状态没有匹配或路径可展示,所以 `SearchCallView` 能携带的东西不会比 generic 标题更多。这是与 terminal 卡片的不对称之处 —— terminal 的调用视图携带执行前就存在的命令、cwd、description;搜索的结构化内容只在 `execute` 之后才存在。
 
@@ -30,7 +30,7 @@ Status: implemented
 
 `SearchMeta` 的成员形状是对象字面量 `type` 别名,而非视图暴露的 `SearchFileMatches`/`SearchLineMatch` 接口,因为只有 type 别名可赋给 `presentationMeta` 返回的 `JsonValue` 索引签名;两者结构等价,所以投影值仍读回为 `SearchResultView`。
 
-Web 前端通过 [Web search 卡片](2026-07-30-web-search-card.md)渲染结构化的 `files`/`paths` 形状。本文负责后端契约及其两个生产者;不具备该卡片的客户端使用原始结果内容回退
+没有专用 `search` 分支的消费方会回退到同一个 generic body,并从原始结果中读取面向模型的文本。因为搜索视图不带自己的 `content`,而本 PR 之前 grep/glob 返回的是 generic 卡片,所以该回退与引入 search 卡片之前的路径逐字节一致。渲染结构化 `files`/`paths` 形状的前端独立于这个后端契约及其两个生产者
 
 ## 考虑过的备选
 
@@ -48,7 +48,7 @@ Web 前端通过 [Web search 卡片](2026-07-30-web-search-card.md)渲染结构
 
 `grep` 与 `glob` 现在在每次非嵌套的成功调用上计算 `presentationMeta`,这是对已保留匹配或路径的一次有界投影 —— 与 render 消费的是同一份保留产出,所以没有第二次保留计算,线上也没有翻倍的搜索文本。序列化 meta 受 `searchMetaMaxBytes` 约束,所以宽泛搜索不再把无界的结构化副本持久化进会话日志。
 
-无 search 卡片的 UI 渲染原始 `tool/result` 内容。渲染结构化形状的 web 消费方读 `truncated`/`total` 与按文件分组;因为视图只携带保留的、字节有界的页,想要完整结果的 UI 跟随面向模型文本里的 spill 定位符,与模型的做法完全一致。
+无 search 卡片的 UI 渲染原始 `tool/result` 内容,所以没有消费方退化。渲染结构化形状的消费方读 `truncated`/`total` 与按文件分组;因为视图只携带保留的、字节有界的页,想要完整结果的 UI 跟随面向模型文本里的 spill 定位符,与模型的做法完全一致。
 
 ## 测试
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-web-read-card.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-web-read-card.md
-2026-07-30-web-read-card.md: a74f971fc1894bbe97b9e7d6a017bbc619337570
-2026-07-30-web-read-card.zh.md: f9da9d6d718480924638db2caa493ca811695592
+2026-07-30-web-read-card.md: 26d1634b6be86666980e65f842de51c868c26efd
+2026-07-30-web-read-card.zh.md: 749177e93e8e3b2e34aed46d1fe99226395a6686

Dosya farkı çok büyük olduğundan ihmal edildi
+ 2 - 3
.agents/notes/implemented/feature/2026-07-30-web-read-card.md


Dosya farkı çok büyük olduğundan ihmal edildi
+ 2 - 3
.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md


+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-web-result-card.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-web-result-card.md
-2026-07-30-web-result-card.md: f3620124bc3b60583f8dbc66517fdd82ec91accf
-2026-07-30-web-result-card.zh.md: 6c3d02fefe5fe9de215736ed221ce7a763aa828e
+2026-07-30-web-result-card.md: 838b13a7f3240c753e5cd1af6909389055c6352d
+2026-07-30-web-result-card.zh.md: 6e5170fcad82d709b050fb05e8efcfe955f20391

+ 3 - 3
.agents/notes/implemented/feature/2026-07-30-web-result-card.md

@@ -16,13 +16,13 @@ One tag with a `kind` discriminant, not two tags. Both calls are web retrieval a
 
 `presentationMeta` carries what render text cannot. The structured result object a tool returns from `execute` does NOT reach a client over the wire — only the model-facing `render` text and, when declared, the `output.presentationMeta` JSON projected onto the `tool/result` event's `meta` do. For `web_search` the meta is the ONLY faithful route to `{url, title?, snippet?, publishedAt?}`: the render collapses those fields into one lossy free-text line, so a consumer cannot reparse them. For `web_fetch` the meta is a smaller but real gain: `url`/`statusCode` are recoverable from the deterministic `Fetched <url> (HTTP <n>)` header line, but `truncated` is the effective truncation — provider cap, pre-conversion source cut, or the deployment's `fetchMaxOutputChars` output cap — which a client cannot recompute because it does not know that cap. The fetch card and the model-facing text derive `truncated` from one shared `renderFetchOutput(result, maxOutputChars)` helper, so the card never disagrees with the footer the model saw. This mirrors the write/edit diff template (`packages/fs/tool-fs/src/diff.ts`): a `*MetaFromValue` projector feeds `output.presentationMeta`, and a `*MetaFromResult` narrower reads `result.meta` back with a defensive fallback to the generic card. `web_fetch`'s body is already markdown in the result content, so it is not duplicated into meta.
 
-Neither result view carries a `content` copy. A UI that does not render the structured `web` card falls back to the raw `tool/result` content. Copying that content into the view would duplicate up to `fetchMaxOutputChars` characters on the same delivered frame for no gain (the same rejection the meta section applies to the fetch body), so the views omit it. Each view sets its result-state `title` from the call args (`args.query` / `args.url`) so a window-truncated replay that dropped the call head still has a title, the way write/edit reset title at result time.
+Neither result view carries a `content` copy. A UI that does not render the structured `web` card falls back to the raw `tool/result` content, the same input a generic card consumes. Copying that content into the view would duplicate up to `fetchMaxOutputChars` characters on the same delivered frame for no gain (the same rejection the meta section applies to the fetch body), so the views omit it and the fallback path renders the identical text. Each view sets its result-state `title` from the call args (`args.query` / `args.url`) so a window-truncated replay that dropped the call head still has a title, the way write/edit reset title at result time.
 
 `presentResult` returns `undefined` (the generic card) on an error result and on absent or malformed `meta`, because presentation runs on replay of arbitrary logged results (possibly from an older schema) and must never throw. The narrowers validate every field defensively; an empty source list is valid meta, not malformed.
 
 ## Consequences
 
-The `web_search`/`web_fetch` `tool/result` events persist a `data.meta` payload while their model-facing render text stays unchanged. The Web client consumes the view through the [web-card frontend](2026-07-30-web-result-card-frontend.md). Any `ToolResultView` consumer that switches exhaustively adds a `web` arm; a generic consumer falls back to the raw result content. `apiproxy`'s session schema accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the view crosses the wire without a schema change.
+The frontend consumer was a separate later PR: this producer change adds the contract arm and makes the two tools emit it, with no client-side rendering. Its one observable change is that the `web_search`/`web_fetch` `tool/result` events persist a `data.meta` payload (the `web-fetch` keyless snapshot was refreshed accordingly); model-facing render text and generic fallback content stay unchanged. The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer change that renders it. Any `ToolResultView` consumer that switches exhaustively must add a `web` arm; a non-exhaustive consumer may use the raw-result fallback. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change.
 
 A future web tool that wants this card declares `presentResult` returning a `card: 'web'` view with its own `kind`; adding a third `kind` is a union edit plus the frontend's branch, not a new card tag.
 
@@ -41,4 +41,4 @@ A future web tool that wants this card declares `presentResult` returning a `car
 ## Related
 
 - [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this extends with the `web` arm.
-- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent that carried the bash `terminal` render intent to the browser; the web frontend consumer follows the same pattern.
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent that carried the bash `terminal` render intent to the browser; the web frontend consumer of this arm is its analogue, deferred to a later PR.

+ 3 - 3
.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md

@@ -16,13 +16,13 @@ Status: implemented
 
 `presentationMeta` 携带 render 文本无法携带的东西。工具从 `execute` 返回的结构化结果对象**不会**经由 wire 抵达客户端——只有面向模型的 `render` 文本,以及(声明时)投影到 `tool/result` 事件 `meta` 上的 `output.presentationMeta` JSON 会。对 `web_search`,meta 是得到 `{url, title?, snippet?, publishedAt?}` 的**唯一**忠实途径:render 把这些字段压进一行有损的自由文本,消费者无法重新解析。对 `web_fetch`,meta 是更小但真实的收益:`url`/`statusCode` 可从确定格式的 `Fetched <url> (HTTP <n>)` header 行还原,但 `truncated` 是有效截断——provider cap、转换前源截断,或部署的 `fetchMaxOutputChars` 输出上限——客户端无法重算,因为它不知道那个上限。抓取卡片与面向模型的文本都从同一个 `renderFetchOutput(result, maxOutputChars)` helper 派生 `truncated`,因此卡片绝不会与模型看到的脚注分叉。这照搬 write/edit 的 diff 模板(`packages/fs/tool-fs/src/diff.ts`):一个 `*MetaFromValue` 投影器喂给 `output.presentationMeta`,一个 `*MetaFromResult` 收窄器读回 `result.meta`,并在失败时防御性回退到 generic 卡片。`web_fetch` 的正文已是结果内容中的 markdown,因此不重复写入 meta。
 
-两个结果视图都不携带 `content` 副本。不渲染结构化 `web` 卡片的 UI 回退到原始 `tool/result` 内容。把该内容复制进视图会在同一投递帧上重复最多 `fetchMaxOutputChars` 个字符却毫无收益(与 meta 一节对抓取正文的否决同理),因此视图省略它。每个视图从调用参数设置其结果期 `title`(`args.query`/`args.url`),因此丢掉了调用头的窗口截断重放仍有标题,与 write/edit 在结果期重设 title 的做法一致。
+两个结果视图都不携带 `content` 副本。不渲染结构化 `web` 卡片的 UI 回退到原始 `tool/result` 内容,这也是 generic 卡片消费的输入。把结果内容复制进视图会在同一投递帧上重复最多 `fetchMaxOutputChars` 个字符却毫无收益(与 meta 一节对抓取正文的否决同理),因此视图省略它,回退路径渲染完全相同的文本。每个视图从调用参数设置其结果期 `title`(`args.query`/`args.url`),因此丢掉了调用头的窗口截断重放仍有标题,与 write/edit 在结果期重设 title 的做法一致。
 
 `presentResult` 在错误结果、以及 `meta` 缺失或畸形时返回 `undefined`(即 generic 卡片),因为 presentation 会在对任意已记录结果(可能来自旧 schema)的重放中运行,绝不能抛错。收窄器防御性地校验每个字段;空来源列表是有效 meta,而非畸形。
 
 ## Consequences
 
-`web_search`/`web_fetch` 的 `tool/result` 事件持久化一个 `data.meta` 载荷,其面向模型的 render 文本保持不变。Web 客户端通过 [web 卡片前端](2026-07-30-web-result-card-frontend.md)消费该视图。任何做穷尽 switch 的 `ToolResultView` 消费方都要新增一个 `web` 分支;generic 消费方回退到原始结果内容。`apiproxy` 的会话 schema 接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此该视图无需 schema 变更即可跨 wire。
+前端消费方由后续独立 PR 交付:本次生产者变更新增契约分支并让两个工具发出它,不含客户端渲染。其唯一可观察的变化是 `web_search`/`web_fetch` 的 `tool/result` 事件持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照当时随之刷新);面向模型的 render 文本与 generic 回退内容保持不变。渲染 `web` 卡片的组装应用 transcript 快照属于渲染它的消费方变更。任何做穷尽 switch 的 `ToolResultView` 消费方都必须新增一个 `web` 分支;非穷尽消费方可以使用原始结果回退。`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。
 
 未来想用此卡片的 web 工具,声明一个返回带自有 `kind` 的 `card: 'web'` 视图的 `presentResult`;新增第三个 `kind` 是一次联合类型编辑加前端的分岔,而非一个新的 card 标签。
 
@@ -41,4 +41,4 @@ Status: implemented
 ## Related
 
 - [标签化的工具调用渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md) —— 本卡片以 `web` 分支扩展的 `card` 标签词汇表。
-- [Web terminal card](2026-07-28-web-terminal-card.md) —— 把 bash `terminal` 渲染意图带到浏览器的先例;web 前端消费方沿用同一模式
+- [Web terminal card](2026-07-28-web-terminal-card.md) —— 把 bash `terminal` 渲染意图带到浏览器的先例;本分支的 web 前端消费者是它的对应物,推迟到后续 PR

+ 2 - 2
.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: e9ec7d0cda37db44e753c9aee572763b7e24ada6
-2026-07-31-web-telemetry-default-mount.zh.md: 68b411d0668772ce81d7f323c2d286714a223ca4
+2026-07-31-web-telemetry-default-mount.md: c1525a44196991d5059969ea70af34501b199323
+2026-07-31-web-telemetry-default-mount.zh.md: f203ea1943387beda445bd81ac78fa2cc0471d45

+ 4 - 4
.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md

@@ -10,15 +10,15 @@ The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry
 
 ## Decision
 
-The shared `dsh` core (`apps/cli/config/base.cordis.yml`) mounts the `telemetry-otel` row by default with a baked-in production endpoint, so every surface — TUI, web, and headless — reports; this is the **internal-testing deployment stance** — reporting is on when an endpoint exists, and users opt out through the environment. Each surface's exit path drains the queue: web/headless dispose on SIGINT/SIGTERM (headless gained those handlers in this change), and the TUI's normal exit runs `disposeRootAndExit` (root dispose, 5s bounded — above the ~1s drain ceiling configured here) while its `/resume` handoff disposes the root before `execve`.
+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.
 
 | Ruling | Value | Rationale |
 |---|---|---|
-| Mount surface | base.cordis.yml (TUI + web + headless) | One deployment stance for every surface; per-surface divergence would need a reason, and none exists |
+| 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 |
 | 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 |
-| Exit-drain bound | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048` (== maxQueueSize) + `exportTimeoutMillis: 1500` | Dispose must release within ~1s against an unreachable collector: timeoutMillis doubles as the per-attempt socket timeout and the retry deadline (1s effectively disables the SDK's 5-try backoff), and aligning batch size with the queue cap makes the drain a single batch; SDK defaults can stall 40s+ |
+| 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 |
 
@@ -30,7 +30,7 @@ The keyless integration test `apps/cli/tests/telemetry-web.e2e.ts` pins the depl
 
 **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.
 
-**A `Promise.race` timeout backstop around exit.** Deferred: the parameter set already bounds the worst-case drain to ~1.5-3s (typically <100ms), measured SIGINT-to-exit 110ms-1.1s; the unbounded drip-feed-response risk stays under observation, and on real evidence the race lands inside the backend's `shutdown()` (never the coordinator — that would decide loss semantics for every backend).
+**A `Promise.race` timeout backstop around exit.** Originally deferred because the SDK parameters appeared to bound the backend's drain to ~1.5-3s (typically <100ms), with measured SIGINT-to-exit of 110ms-1.1s. A Linux sandbox reproduction later proved that `BatchLogRecordProcessor.shutdown()` can wait forever in `exporter.forceFlush()` before reaching its `exportTimeoutMillis`-bounded completion Promise. The [CLI shutdown fix](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) therefore adds both a three-second backend bound for that specific gap and a five-second process-level bound plus repeated-signal escape for the whole plugin tree.
 
 ## Consequences
 

+ 4 - 4
.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md

@@ -10,15 +10,15 @@ Status: implemented
 
 ## Decision
 
-`dsh` 共享核心(`apps/cli/config/base.cordis.yml`)默认挂载 `telemetry-otel` 行,内置生产 endpoint,因此所有 surface——TUI、web、headless——都上报;这是**内部测试期的部署立场**——有 endpoint 就报,用户可经环境变量退出。各 surface 的退出路径都会排空队列:web/headless 在 SIGINT/SIGTERM 上 dispose(headless 的信号处理是本次补上的),TUI 的正常退出走 `disposeRootAndExit`(根 dispose,5s 兜底——高于此处配置的 ~1s drain 上界),其 `/resume` 移交也在 `execve` 前 dispose 根
+`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 秒关闭截止时间完成排空
 
 | 决策项 | 取值 | 理由 |
 |---|---|---|
-| 挂载面 | base.cordis.yml(TUI + web + headless) | 所有 surface 一个部署立场;按 surface 分化需要理由,而当前没有 |
+| 挂载面 | base.cordis.yml(原始配置 + Web + headless) | 所有加载共享 base 的配置树采用同一个部署立场;原始配置 overlay 决定该部署是否创建会话 |
 | 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/批) | 流式回流,非退出才报;崩溃至多丢最后一个未导出间隔 |
-| 退出 drain 上界 | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048(== maxQueueSize)` + `exportTimeoutMillis: 1500` | collector 不可达时 dispose 必须 ~1s 内放行:timeoutMillis 同时是单次 socket 超时与重试 deadline(1s 等效关掉 SDK 5 次 backoff),批大小对齐队列上限使 drain 恒为单批;默认参数下最坏可卡 40s+ |
+| 退出 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 workflow 顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | CI 启动 web 组合的所有通道(e2e/snapshot/built smoke)不得向生产 endpoint 泄测试会话 |
 
@@ -30,7 +30,7 @@ Status: implemented
 
 **开关做成 config 字段而非 env patch。** 不可行:cordis 行没有 config 层的 disable 语义,且 `exporter.url` 校验在插件构造期 fail-loud,开关必须在 Loader 之前生效——AppCLIEntry patch 层是唯一落点。
 
-**退出时 `Promise.race` 兜底超时。** 暂缓:参数组合已把最坏 drain 压到 ~1.5-3s(典型 <100ms),实测 SIGINT→退出 110ms-1.1s;drip-feed 慢滴响应的无界等待风险留观,出现实证再在 backend `shutdown()` 内加 race(不放 coordinator——那会替所有 backend 决定丢失语义)
+**退出时 `Promise.race` 兜底超时。** 最初暂缓,是因为 SDK 参数看似已经将后端排空耗时限制在约 1.5-3s(通常 <100ms),实测 SIGINT 到退出耗时 110ms-1.1s。后来在 Linux 沙箱中复现并证明,`BatchLogRecordProcessor.shutdown()` 可能在 `exporter.forceFlush()` 中永久等待,无法进入受 `exportTimeoutMillis` 限制的完成 Promise。因此,[CLI 关闭修复](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) 既为这一特定缺口增加 3 秒后端上限,也为整棵插件树增加 5 秒进程级上限和重复信号退出途径
 
 ## Consequences
 

+ 2 - 2
apps/cli/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/README.md
-README.md: 360ab26ec3dfecf2841a012fda8947d6a84fdfec
-README.zh.md: 3ffa5d7726a798b784c67fdb8c4154fddbdea7a4
+README.md: 6fdca68eed11dffe46bf2fbde9a7899359690dca
+README.zh.md: d8d7122729df1dd8aaed8207ddfb0a0470778b01

+ 2 - 0
apps/cli/README.md

@@ -49,6 +49,8 @@ The production Web runner needs built package and frontend artifacts (`pnpm run
 
 `dsh -p "task"` uses the same base and Web composition with the startup personal config, starts its Web host on an OS-assigned port, runs one fresh persisted session, prints the final answer, and exits. It accepts neither `--config` nor raw config-dump flags.
 
+Web and headless process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If headless normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
+
 Both modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Web watches valid personal config edits; headless reads the file once at startup. The [app-boot personal-config contract](../../packages/ui/app-boot/README.md#personal-config) owns layer precedence, credential storage, live-update failure behavior, and `$DSH_HOME` resolution.
 
 New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.

+ 2 - 0
apps/cli/README.zh.md

@@ -49,6 +49,8 @@ dsh web --dump-config
 
 `dsh -p "task"` 使用相同的 base 与 Web 组合及启动时个人配置,在由操作系统分配的端口上启动 Web 宿主,运行一个全新的持久会话,打印最终答案后退出。它不接受 `--config` 或原始配置输出标志。
 
+Web 与 headless 的进程关闭流程最多给插件树 5 秒执行 dispose。第一次 `SIGINT`/`SIGTERM` 会启动这次优雅排空;第二次信号会立即强制退出。如果 headless 的正常完成流程已经卡在 dispose 中,第一次 `Ctrl+C` 就会触发强制退出:进程立即结束,该信号不再被吞掉。
+
 两种模式都以调用目录作为默认 workspace 根目录,加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,渲染预算为 65,536 字节,并使用内存 SQLite 会话内容索引。Web 会持续应用有效的个人配置编辑;headless 只在启动时读取该文件一次。层次优先级、凭据存储、实时更新失败行为与 `$DSH_HOME` 解析均由 [app-boot 个人配置契约](../../packages/ui/app-boot/README.md#personal-config) 统一定义。
 
 新会话默认使用 `workspace-write` 权限 preset。Bash 和文件系统写操作受限于会话 workspace 与平台临时根目录;读取、网络访问与进程可见性不受限制。`DSH_PERMISSION_MODE` 会改变进程回退值。已存储的常规设置权限会影响之后的 Web 会话,不会更改已打开的会话。

+ 10 - 8
apps/cli/config/base.cordis.yml

@@ -111,17 +111,19 @@
 # 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,
 # random UUID; delete the file to reset the identity) as the Resource's
-# user.id. The exporter/processor values bound the shutdown drain to ~1s
-# against an unreachable collector: exporter.timeoutMillis is both the
-# per-attempt socket timeout and the retry deadline (1s effectively
-# disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize
-# (both explicit) makes the drain a single batch, and exportTimeoutMillis
-# is the processor's own cap on that one export cycle — the second bound
-# when the exporter's clock alone does not fire. Every CLI exit path drains it
-# by disposing the root on SIGINT/SIGTERM.
+# user.id. The exporter/processor values normally bound the shutdown drain
+# to ~1s against an unreachable collector: exporter.timeoutMillis is both
+# the per-attempt socket timeout and the retry deadline (1s effectively
+# disables the SDK's 5-try backoff), while maxExportBatchSize == maxQueueSize
+# (both explicit) makes the drain a single batch. The SDK awaits
+# exporter.forceFlush() outside exportTimeoutMillis, so the backend's 3s
+# shutdownTimeoutMillis is the load-bearing outer bound when a transport
+# promise never settles. Every CLI exit path drains it by disposing the root
+# on SIGINT/SIGTERM.
 - id: telemetry-otel
   name: '@deepseek-ai/dsh-session-telemetry-otel'
   config:
+    shutdownTimeoutMillis: 3000
     exporter:
       url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
       compression: gzip

+ 12 - 19
apps/cli/src/headless.ts

@@ -14,6 +14,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
 import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import type { SessionId } from '@deepseek-ai/dsh-session'
 import { AppCLIEntry } from './app-cli-entry.ts'
+import { createProcessShutdown } from './process-shutdown.ts'
 
 /** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
 interface TurnOutcome {
@@ -21,12 +22,12 @@ interface TurnOutcome {
   reason: string
 }
 
-/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (dispose first). */
-async function unwrap<T>(response: RpcResponse<T>, dispose: () => Promise<void>): Promise<T> {
+/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (shutdown first). */
+async function unwrap<T>(response: RpcResponse<T>, shutdown: () => Promise<void>): Promise<T> {
   if (response.result.ok) return response.result.value
   const { code, message } = response.result.error
   process.stderr.write(`dsh: ${code}: ${message}\n`)
-  await dispose()
+  await shutdown()
   process.exit(1)
 }
 
@@ -93,23 +94,16 @@ export async function runHeadless(task: string): Promise<void> {
     port: 0,
   })
   const { ctx, port } = await entry.run()
-  const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
-  // Signal exits must still dispose the tree: the composition mounts
-  // exit-drained plugins (telemetry's queued tail and shutdown marker would
-  // otherwise be lost), and Node's default signal exit skips disposal.
-  let signalled = false
-  const disposeAndExit = (code: number): void => {
-    if (signalled) return
-    signalled = true
-    void dispose().finally(() => { process.exit(code) })
-  }
-  process.on('SIGTERM', () => { disposeAndExit(143) })
-  process.on('SIGINT', () => { disposeAndExit(130) })
+  // Normal completion and signals share one bounded drain. A signal received
+  // during that drain escalates immediately instead of becoming a no-op.
+  const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
+  process.on('SIGTERM', () => { shutdown.interrupt(143) })
+  process.on('SIGINT', () => { shutdown.interrupt(130) })
   // The headless session is web-observable while it runs (same composition).
   process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
   const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
 
-  const created = await unwrap(await api.sessions.create({}), dispose)
+  const created = await unwrap(await api.sessions.create({}), () => shutdown.shutdown(1))
 
   // Open the stream before prompting so no frame is lost — kept in this order
   // even though in-process delivery has no race, so the code survives a move
@@ -127,11 +121,10 @@ export async function runHeadless(task: string): Promise<void> {
     sessionId: created.sessionId,
     mode: 'queue',
     content: [{ type: 'text', text: task }],
-  }), dispose)
+  }), () => shutdown.shutdown(1))
 
   const outcome = await done
   process.stdout.write(outcome.text + '\n')
   abort.abort()
-  await dispose()
-  process.exit(outcome.reason === 'completed' ? 0 : 1)
+  await shutdown.shutdown(outcome.reason === 'completed' ? 0 : 1)
 }

+ 58 - 0
apps/cli/src/process-shutdown.ts

@@ -0,0 +1,58 @@
+/** Bounded, escalating process shutdown for the long-lived CLI surfaces. */
+
+/** Maximum grace allowed for the application tree to dispose before process exit. */
+export const PROCESS_SHUTDOWN_TIMEOUT_MS = 5_000
+
+/** Process-exit controller shared by normal completion and Unix signal handlers. */
+export interface ProcessShutdown {
+  /** Start or join graceful disposal before exiting with `code`. */
+  shutdown(code: number): Promise<void>
+  /** Start graceful disposal, or force exit when a shutdown is already running. */
+  interrupt(code: number): void
+}
+
+/**
+ * Create one process-exit controller around an application disposer.
+ * @param dispose - Whole-application teardown that resolves at quiescence.
+ * @param exit - Process exit boundary, replaceable by tests.
+ * @param timeoutMs - Grace before forced exit, replaceable by tests.
+ * @returns A controller whose normal calls coalesce and whose repeated signal call escalates.
+ */
+export function createProcessShutdown(
+  dispose: () => Promise<void>,
+  exit: (code: number) => void = (code) => { process.exit(code) },
+  timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS,
+): ProcessShutdown {
+  let pending: Promise<void> | undefined
+  let timeout: ReturnType<typeof setTimeout> | undefined
+  let exited = false
+
+  const exitOnce = (code: number): void => {
+    if (exited) return
+    exited = true
+    /* v8 ignore else -- shutdown() arms the timer before any asynchronous exit path can run. */
+    if (timeout !== undefined) clearTimeout(timeout)
+    exit(code)
+  }
+
+  const shutdown = (code: number): Promise<void> => {
+    if (pending !== undefined) return pending
+    timeout = setTimeout(() => { exitOnce(code) }, timeoutMs)
+    pending = Promise.resolve().then(dispose).then(
+      () => { exitOnce(code) },
+      () => { exitOnce(code) },
+    )
+    return pending
+  }
+
+  return {
+    shutdown,
+    interrupt(code) {
+      if (pending !== undefined) {
+        exitOnce(code)
+        return
+      }
+      void shutdown(code)
+    },
+  }
+}

+ 4 - 8
apps/cli/src/web.ts

@@ -13,6 +13,7 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 import type {} from '@deepseek-ai/dsh-tool-bash'
 import { AppCLIEntry } from './app-cli-entry.ts'
+import { createProcessShutdown } from './process-shutdown.ts'
 
 // The shipped base plus the Web application's overlay.
 const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
@@ -118,17 +119,12 @@ export async function runWeb(
   const { ctx, port: boundPort } = await entry.run()
   const resolvedLocalWebUrl = localWebUrl(ctx)
 
-  let exiting = false
-  const shutdown = (code: number): void => {
-    if (exiting) return
-    exiting = true
-    void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
-  }
+  const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
 
   // Install shutdown handling before publishing readiness: supervisors may
   // send a signal as soon as they observe the URL line.
-  process.on('SIGTERM', () => { shutdown(0) })
-  process.on('SIGINT', () => { shutdown(130) })
+  process.on('SIGTERM', () => { shutdown.interrupt(0) })
+  process.on('SIGINT', () => { shutdown.interrupt(130) })
 
   // The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
   // must name an address the /api trust fence was configured with.

+ 18 - 0
apps/cli/tests/fixtures/never-dispose.mjs

@@ -0,0 +1,18 @@
+/** Test-only Cordis plugin whose disposer announces entry and never settles. */
+
+import { existsSync } from 'node:fs'
+
+/**
+ * Register a disposer that keeps process shutdown pending until it is forced.
+ * @param {import('cordis').Context} ctx - loader-mounted test plugin context.
+ */
+export function apply(ctx) {
+  const keepAlive = setInterval(() => {}, 60_000)
+  ctx.effect(() => async () => {
+    clearInterval(keepAlive)
+    const armFile = process.env.DSH_TEST_SHUTDOWN_ARM_FILE
+    if (armFile === undefined || !existsSync(armFile)) return
+    process.stderr.write('dsh-test: never-dispose started\n')
+    await new Promise(() => {})
+  })
+}

+ 121 - 0
apps/cli/tests/headless-shutdown.e2e.ts

@@ -0,0 +1,121 @@
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { execa } from 'execa'
+import { describe, expect, it } from 'vitest'
+import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
+
+const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
+const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
+const neverDisposePlugin = pathToFileURL(
+  fileURLToPath(new URL('./fixtures/never-dispose.mjs', import.meta.url)),
+).href
+
+const POSIX_HEADLESS_PTY_DRIVER = String.raw`
+import errno, json, os, pty, select, signal, sys, time
+node, launch_args_json, launch_env_json, cwd, timeout_seconds = sys.argv[1:]
+env = os.environ.copy()
+env.update(json.loads(launch_env_json))
+pid, fd = pty.fork()
+if pid == 0:
+    os.chdir(cwd)
+    os.execvpe(node, [node, *json.loads(launch_args_json)], env)
+
+markers = [b"dsh: observing at ", b"dsh-test: never-dispose started"]
+output = bytearray()
+marker_index = 0
+deadline = time.monotonic() + float(timeout_seconds)
+status = None
+while time.monotonic() < deadline:
+    ready, _, _ = select.select([fd], [], [], 0.05)
+    if ready:
+        try:
+            chunk = os.read(fd, 65536)
+        except OSError as error:
+            if error.errno != errno.EIO:
+                raise
+            chunk = b""
+        if chunk:
+            output.extend(chunk)
+    while marker_index < len(markers) and markers[marker_index] in output:
+        if marker_index == 0:
+            open(os.path.join(cwd, "shutdown-armed"), "w").close()
+        os.write(fd, b"\x03")
+        marker_index += 1
+    waited, candidate = os.waitpid(pid, os.WNOHANG)
+    if waited == pid:
+        status = candidate
+        break
+
+if status is None:
+    os.kill(pid, signal.SIGKILL)
+    _, status = os.waitpid(pid, 0)
+sys.stdout.buffer.write(output)
+if marker_index != len(markers):
+    sys.stderr.write(f"completed {marker_index}/{len(markers)} PTY actions before timeout\n")
+    sys.exit(124)
+actual_exit = os.waitstatus_to_exitcode(status)
+if actual_exit != 130:
+    sys.stderr.write(f"expected exit 130, got {actual_exit}\n")
+    sys.exit(125)
+`
+
+async function runHeadlessPtySmoke(): Promise<string> {
+  const cwd = await mkdtemp(join(tmpdir(), 'dsh-headless-shutdown-'))
+  try {
+    const home = join(cwd, '.dsh')
+    await mkdir(home, { recursive: true })
+    await writeFile(join(home, 'config.yaml'), [
+      '- insert:',
+      '    - id: never-dispose',
+      `      name: '${neverDisposePlugin}'`,
+      '',
+    ].join('\n'))
+    const launch = resolveExampleLaunch({
+      srcBin: dshBinScript,
+      configArgs: ['-p', 'never complete'],
+      tsconfigPath,
+      env: {
+        DSH_HOME: home,
+        DSH_AGENTS_HOME: join(cwd, '.agents'),
+        DEEPSEEK_API_KEY: 'keyless-shutdown-no-call',
+        DSH_TELEMETRY_DISABLED: '1',
+        DSH_TEST_SHUTDOWN_ARM_FILE: join(cwd, 'shutdown-armed'),
+      },
+    })
+    const timeoutMs = 15_000
+    const result = await execa('python3', [
+      '-c',
+      POSIX_HEADLESS_PTY_DRIVER,
+      launch.command,
+      JSON.stringify(launch.args),
+      JSON.stringify(launch.env),
+      cwd,
+      String(timeoutMs / 1_000),
+    ], {
+      stdin: 'ignore',
+      timeout: timeoutMs + 5_000,
+      killSignal: 'SIGKILL',
+      reject: false,
+      stripFinalNewline: false,
+    })
+    if (result.timedOut) {
+      throw new Error(`dsh headless PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
+    }
+    if (result.failed) {
+      throw new Error(`dsh headless PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
+    }
+    return result.stdout
+  } finally {
+    await rm(cwd, { recursive: true, force: true })
+  }
+}
+
+describe.skipIf(process.platform === 'win32')('headless process shutdown (real Loader tree in a PTY)', () => {
+  it('lets a second Ctrl+C force exit while the first signal is draining', async () => {
+    const output = await runHeadlessPtySmoke()
+    expect(output).toContain('dsh: observing at ')
+    expect(output).toContain('dsh-test: never-dispose started')
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+})

+ 131 - 0
apps/cli/tests/process-shutdown.spec.ts

@@ -0,0 +1,131 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+  createProcessShutdown,
+  PROCESS_SHUTDOWN_TIMEOUT_MS,
+} from '../src/process-shutdown.ts'
+
+function deferred(): { promise: Promise<void>; resolve: () => void; reject: (error: Error) => void } {
+  let resolve!: () => void
+  let reject!: (error: Error) => void
+  const promise = new Promise<void>((accept, fail) => {
+    resolve = accept
+    reject = fail
+  })
+  return { promise, resolve, reject }
+}
+
+afterEach(() => {
+  vi.useRealTimers()
+  vi.restoreAllMocks()
+})
+
+describe('process shutdown', () => {
+  it('exits once after graceful disposal resolves or rejects', async () => {
+    const resolvedExit = vi.fn()
+    const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit)
+    await resolved.shutdown(0)
+    expect(resolvedExit).toHaveBeenCalledOnce()
+    expect(resolvedExit).toHaveBeenCalledWith(0)
+
+    const rejectedExit = vi.fn()
+    const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit)
+    await rejected.shutdown(1)
+    expect(rejectedExit).toHaveBeenCalledOnce()
+    expect(rejectedExit).toHaveBeenCalledWith(1)
+  })
+
+  it('uses process.exit as the default process boundary', async () => {
+    const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
+    const shutdown = createProcessShutdown(() => Promise.resolve())
+
+    await shutdown.shutdown(7)
+
+    expect(exit).toHaveBeenCalledOnce()
+    expect(exit).toHaveBeenCalledWith(7)
+  })
+
+  it('forces exit when graceful disposal reaches its bound', async () => {
+    vi.useFakeTimers()
+    const disposal = deferred()
+    const exit = vi.fn()
+    const shutdown = createProcessShutdown(() => disposal.promise, exit)
+    const pending = shutdown.shutdown(0)
+
+    await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
+    expect(exit).not.toHaveBeenCalled()
+    await vi.advanceTimersByTimeAsync(1)
+    expect(exit).toHaveBeenCalledOnce()
+    expect(exit).toHaveBeenCalledWith(0)
+
+    disposal.resolve()
+    await pending
+    expect(exit).toHaveBeenCalledOnce()
+  })
+
+  it('honors a caller-supplied grace period', async () => {
+    vi.useFakeTimers()
+    const disposal = deferred()
+    const exit = vi.fn()
+    const shutdown = createProcessShutdown(() => disposal.promise, exit, 25)
+    const pending = shutdown.shutdown(0)
+
+    await vi.advanceTimersByTimeAsync(24)
+    expect(exit).not.toHaveBeenCalled()
+    await vi.advanceTimersByTimeAsync(1)
+    expect(exit).toHaveBeenCalledOnce()
+
+    disposal.resolve()
+    await pending
+  })
+
+  it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
+    const disposal = deferred()
+    const exit = vi.fn()
+    const shutdown = createProcessShutdown(() => disposal.promise, exit)
+    const pending = shutdown.shutdown(0)
+
+    shutdown.interrupt(130)
+    expect(exit).toHaveBeenCalledOnce()
+    expect(exit).toHaveBeenCalledWith(130)
+
+    disposal.resolve()
+    await pending
+    expect(exit).toHaveBeenCalledOnce()
+  })
+
+  it('drains on the first signal and forces on the second signal', async () => {
+    const disposal = deferred()
+    const dispose = vi.fn(() => disposal.promise)
+    const exit = vi.fn()
+    const shutdown = createProcessShutdown(dispose, exit)
+
+    shutdown.interrupt(143)
+    await Promise.resolve()
+    expect(dispose).toHaveBeenCalledOnce()
+    expect(exit).not.toHaveBeenCalled()
+
+    shutdown.interrupt(130)
+    expect(exit).toHaveBeenCalledOnce()
+    expect(exit).toHaveBeenCalledWith(130)
+
+    disposal.resolve()
+    await shutdown.shutdown(0)
+    expect(exit).toHaveBeenCalledOnce()
+  })
+
+  it('coalesces normal shutdown calls without treating them as escalation', async () => {
+    const disposal = deferred()
+    const exit = vi.fn()
+    const shutdown = createProcessShutdown(() => disposal.promise, exit)
+
+    const first = shutdown.shutdown(0)
+    const second = shutdown.shutdown(1)
+    expect(second).toBe(first)
+    expect(exit).not.toHaveBeenCalled()
+
+    disposal.resolve()
+    await first
+    expect(exit).toHaveBeenCalledOnce()
+    expect(exit).toHaveBeenCalledWith(0)
+  })
+})

+ 5 - 4
docs/config-catalog.md

@@ -1219,10 +1219,9 @@ Requires: `sessions`
 
 ```ts config-catalog
 /**
- * Plugin configuration: two verbatim SDK option shapes plus nothing else.
- * `exporter.url` is the one field this package validates itself — required,
- * no default, must parse as an `http(s)` URL — because a missing endpoint
- * must fail at plugin load, not at first export.
+ * Plugin configuration: two verbatim SDK option shapes plus one DSH-owned
+ * shutdown bound. The package validates its endpoint and shutdown deadline
+ * because both must fail at plugin load rather than at first export or exit.
  */
 export interface Config {
   /**
@@ -1240,6 +1239,8 @@ export interface Config {
    * which this plugin fills); the SDK owns and documents these knobs.
    */
   processor?: Omit<BatchLogRecordProcessorOptions, 'exporter'>
+  /** Maximum time spent awaiting the SDK provider's complete shutdown path. */
+  shutdownTimeoutMillis?: number
 }
 ```
 

+ 2 - 2
packages/telemetry/session-telemetry-otel/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/telemetry/session-telemetry-otel/README.md
-README.md: ad4a97868c28dc3873c839490aa506271459e249
-README.zh.md: f1ad73ddf66aacc30a9024a9290df2c44686efe9
+README.md: 3abd97187cafee132823c02a0b0d103a86bda7db
+README.zh.md: 223e6a663933da81032a1fbbb4211555c4bcc159

+ 2 - 1
packages/telemetry/session-telemetry-otel/README.md

@@ -10,6 +10,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
 - id: telemetry-otel
   name: '@deepseek-ai/dsh-session-telemetry-otel'
   config:
+    shutdownTimeoutMillis: 3000 # optional; defaults to 3000
     exporter:                # passed verbatim to the SDK's OTLP/HTTP log exporter
       url: https://collector.example.com/v1/logs
       headers:
@@ -17,7 +18,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
     processor: {}            # optional; passed verbatim to BatchLogRecordProcessor
 ```
 
-`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load (as does a non-positive-integer `processor.maxExportBatchSize`, which the SDK accepts but then hangs on at shutdown). Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. The backend deliberately implements no `flush()`: the batch processor is the only flusher in the process, which is what makes `shutdown()`'s drain complete. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag.
+`exporter.url` is required, has no default, and must parse as `http(s)`; `shutdownTimeoutMillis` is a positive finite DSH-owned outer deadline and defaults to 3000 ms; a non-positive-integer `processor.maxExportBatchSize` also fails at plugin load because the SDK accepts it but then hangs on shutdown. Both SDK blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are SDK behavior tuned through `processor`. The backend implements no `flush()`: the batch processor owns ordinary flushing. During shutdown, however, OTel awaits `exporter.forceFlush()` before the processor's `exportTimeoutMillis`-bounded completion promise; if that transport promise never settles, this package abandons the wait at `shutdownTimeoutMillis`, logs the contained shutdown failure through the coordinator, and lets application teardown continue. The deadline cannot cancel the SDK transport, so records still pending then may be lost at process exit. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag.
 
 ## What leaves the machine
 

+ 2 - 1
packages/telemetry/session-telemetry-otel/README.zh.md

@@ -10,6 +10,7 @@
 - id: telemetry-otel
   name: '@deepseek-ai/dsh-session-telemetry-otel'
   config:
+    shutdownTimeoutMillis: 3000 # optional; defaults to 3000
     exporter:                # passed verbatim to the SDK's OTLP/HTTP log exporter
       url: https://collector.example.com/v1/logs
       headers:
@@ -17,7 +18,7 @@
     processor: {}            # optional; passed verbatim to BatchLogRecordProcessor
 ```
 
-`exporter.url` 是本包(package)唯一自行校验的字段:必填、无默认值、必须能解析为 `http(s)`,因此缺失端点会在插件加载时失败(`processor.maxExportBatchSize` 不是正整数时同样如此:SDK 会接受该值,随后却在关闭时因它挂起)。其余全部是 SDK 自己的选项形态,由 SDK 拥有并在 SDK 文档中说明,两个配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是 SDK 的文档化行为,经 `processor` 透传调优。该后端刻意不实现 `flush()`:批处理器是进程内唯一执行 flush 的组件,`shutdown()` 的排空正因如此才是完整的。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。
+`exporter.url` 是必填项、没有默认值,并且必须能解析为 `http(s)`;`shutdownTimeoutMillis` 是由 DSH 管理的有限正数外层截止时间,默认值为 3000 ms;`processor.maxExportBatchSize` 不是正整数时也会在插件加载时失败,因为 SDK 会接受该值,随后却在关闭时挂起。两个 SDK 配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是通过 `processor` 调节的 SDK 行为。该后端不实现 `flush()`:常规 flush 由批处理器负责。但在关闭期间,OTel 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的处理器完成 promise;如果该传输 promise 始终不结算,本包(package)会在 `shutdownTimeoutMillis` 到期时放弃等待,沿协调器现有的失败隔离路径记录关闭失败,并让应用继续拆卸。该截止时间无法取消 SDK 传输,因此届时仍待处理的记录可能在进程退出时丢失。从 `cordis.yml` 中删除该配置块即为退出方式:无残留状态,也没有 `enabled` 开关。
 
 ## 哪些数据会离开本机
 

+ 45 - 18
packages/telemetry/session-telemetry-otel/src/index.ts

@@ -6,8 +6,9 @@
  * record handed over by the seam onto `logger.emit()`. Per the seam's
  * boundary axiom, everything downstream of that call (batching, retry,
  * queueing, loss policy) is the SDK's documented behavior, configured
- * verbatim through the `exporter`/`processor` passthroughs; this package
- * adds no knobs of its own on top of them.
+ * verbatim through the `exporter`/`processor` passthroughs. The one
+ * backend-owned policy is an outer shutdown deadline: the SDK's export
+ * timeout does not bound its preceding `forceFlush()` wait.
  *
  * @module @deepseek-ai/dsh-session-telemetry-otel
  */
@@ -33,10 +34,9 @@ import { resourceFromAttributes } from '@opentelemetry/resources'
 const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
 
 /**
- * Plugin configuration: two verbatim SDK option shapes plus nothing else.
- * `exporter.url` is the one field this package validates itself — required,
- * no default, must parse as an `http(s)` URL — because a missing endpoint
- * must fail at plugin load, not at first export.
+ * Plugin configuration: two verbatim SDK option shapes plus one DSH-owned
+ * shutdown bound. The package validates its endpoint and shutdown deadline
+ * because both must fail at plugin load rather than at first export or exit.
  */
 export interface Config {
   /**
@@ -54,21 +54,31 @@ export interface Config {
    * which this plugin fills); the SDK owns and documents these knobs.
    */
   processor?: Omit<BatchLogRecordProcessorOptions, 'exporter'>
+  /** Maximum time spent awaiting the SDK provider's complete shutdown path. */
+  shutdownTimeoutMillis?: number
 }
 
 /**
  * Schemastery validator for {@link Config}; cordis runs it before the plugin
- * starts. Shape-level only — the load-bearing `exporter.url` check lives in
- * the constructor so its error message names the field. Both slots are opaque
- * passthroughs: the SDK owns their shapes and validates its own options;
+ * starts. Shape-level only — load-bearing value checks live in the constructor
+ * so their errors name the fields. Both SDK slots are opaque passthroughs:
+ * the SDK owns their shapes and validates its own options;
  * re-declaring them field-by-field here would violate the boundary axiom
  * (and silently drop every field not re-declared).
  */
 export const Config: z<Config> = z.object({
   exporter: z.any(),
   processor: z.any(),
+  shutdownTimeoutMillis: z.number(),
 })
 
+/** Default outer allowance for the SDK's complete shutdown sequence. */
+export const DEFAULT_SHUTDOWN_TIMEOUT_MILLIS = 3_000
+
+// Node clamps larger timer delays to one millisecond. This is a runtime
+// protocol limit, not a deployment default.
+const MAX_TIMER_DELAY_MILLIS = 2_147_483_647
+
 /** Severity mapping from the seam's three-level vocabulary to OTel severity numbers. */
 const SEVERITY: Record<TelemetrySeverity, { severityNumber: SeverityNumber; severityText: string }> = {
   info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' },
@@ -90,6 +100,7 @@ export class TelemetryOtel extends Telemetry {
   private readonly provider: LoggerProvider
   private readonly ledger: Logger
   private readonly ops: Logger
+  private readonly shutdownTimeoutMillis: number
 
   constructor(ctx: Context, config: Config) {
     super(ctx)
@@ -115,6 +126,11 @@ export class TelemetryOtel extends Telemetry {
     if (batchSize !== undefined && (!Number.isInteger(batchSize) || batchSize < 1)) {
       throw new Error(`session-telemetry-otel: processor.maxExportBatchSize must be a positive integer, got ${String(batchSize)}`)
     }
+    const shutdownTimeoutMillis = config.shutdownTimeoutMillis ?? DEFAULT_SHUTDOWN_TIMEOUT_MILLIS
+    if (!Number.isFinite(shutdownTimeoutMillis) || shutdownTimeoutMillis <= 0 || shutdownTimeoutMillis > MAX_TIMER_DELAY_MILLIS) {
+      throw new Error(`session-telemetry-otel: shutdownTimeoutMillis must be a positive finite number no greater than ${MAX_TIMER_DELAY_MILLIS}, got ${String(shutdownTimeoutMillis)}`)
+    }
+    this.shutdownTimeoutMillis = shutdownTimeoutMillis
     this.provider = new LoggerProvider({
       resource: resourceFromAttributes({
         'service.name': APP_IDENTITY.product,
@@ -170,16 +186,27 @@ export class TelemetryOtel extends Telemetry {
   // the revival Agent Note.
 
   /**
-   * Delegate disposal to the SDK's shutdown contract: drain the queue and
-   * quiesce. With no concurrent `forceFlush()` in the process (see above),
-   * shutdown's internal drain is complete — everything emitted before this
-   * call, including the coordinator's dispose-time `shutdown` markers, is
-   * exported before the exporter closes. Awaited (and error-contained) by
-   * the coordinator's disposer.
-   * @returns resolves when the SDK pipeline has quiesced.
+   * Ask the SDK to drain and quiesce, but reject after the backend-owned
+   * deadline. OTel's processor export timeout wraps `exportCompleted` only;
+   * shutdown awaits `exporter.forceFlush()` first, which can remain pending
+   * when the transport never obtains a socket. The provider promise remains
+   * observed after the deadline so a later rejection cannot become unhandled.
+   * @returns resolves when the SDK pipeline quiesces, or rejects at the configured deadline.
    */
-  shutdown(): Promise<void> {
-    return this.provider.shutdown()
+  async shutdown(): Promise<void> {
+    const providerShutdown = this.provider.shutdown()
+    let timer: ReturnType<typeof setTimeout> | undefined
+    const deadline = new Promise<never>((_resolve, reject) => {
+      timer = setTimeout(() => {
+        reject(new Error(`session-telemetry-otel: provider shutdown exceeded ${this.shutdownTimeoutMillis}ms`))
+      }, this.shutdownTimeoutMillis)
+    })
+    try {
+      await Promise.race([providerShutdown, deadline])
+    } finally {
+      /* v8 ignore else -- the Promise executor assigns timer synchronously before this race starts. */
+      if (timer !== undefined) clearTimeout(timer)
+    }
   }
 }
 

+ 34 - 0
packages/telemetry/session-telemetry-otel/tests/otel.spec.ts

@@ -180,6 +180,38 @@ describe('TelemetryOtel wire', () => {
     expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } })
   })
 
+  it('bounds the SDK forceFlush wait when an in-flight transport never settles', async () => {
+    const gate = Promise.withResolvers<boolean>()
+    const arrived = Promise.withResolvers<boolean>()
+    const { url, captures } = await mockCollector(async (index) => {
+      if (index === 0) {
+        arrived.resolve(true)
+        await gate.promise
+      }
+    })
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const fiber = await ctx.plugin(TelemetryOtel, {
+      exporter: { url, timeoutMillis: 60_000 },
+      processor: { scheduledDelayMillis: 10, exportTimeoutMillis: 60_000 },
+      shutdownTimeoutMillis: 50,
+    })
+    const session = ctx.sessions.create(SessionId('bounded-shutdown'), { meta: {} })
+    session.append('turn/start', { turn: 1 })
+    await arrived.promise
+
+    const started = performance.now()
+    await fiber.dispose()
+    expect(performance.now() - started).toBeLessThan(1_000)
+    expect(captures).toHaveLength(0)
+
+    // The outer deadline cannot cancel the SDK transport. Let it finish so
+    // the real provider promise remains clean after the test has proved the
+    // Cordis disposer no longer waits for it.
+    gate.resolve(true)
+    await expect.poll(() => captures.length).toBeGreaterThanOrEqual(2)
+  })
+
   it('passes exporter options beyond url and headers through to the SDK exporter', async () => {
     const { url, captures } = await mockCollector()
     const ctx = new Context()
@@ -227,6 +259,8 @@ describe('TelemetryOtel config fails loud', () => {
     // 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/],
   ])('rejects %j at plugin load', async (config, message) => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor