Merge remote-tracking branch 'origin/master' into worktree/web-theme-settings-integration-fde706
# Conflicts: # docs/module-graph.md # packages/client/runtime/README.i18n.yaml # packages/client/runtime/tsconfig.json # packages/client/ui-conversation/README.i18n.yaml # packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-error-cause-chain-diagnostics.md
|
||||
2026-07-20-error-cause-chain-diagnostics.md: 7de1f4f631cec90048ccc8eab7a6654560d84846
|
||||
2026-07-20-error-cause-chain-diagnostics.zh.md: 6ce642eb4a18a1a412fb63c3bb00042a921f4da3
|
||||
2026-07-20-error-cause-chain-diagnostics.md: 89edd4529cc16617fdca736dbbea4b170400d6bd
|
||||
2026-07-20-error-cause-chain-diagnostics.zh.md: 673921c913a9f1fefe6f85303cf5952f8edbcc1d
|
||||
|
||||
@@ -34,4 +34,4 @@ A TUI run against an unreachable DeepSeek endpoint failed with the single notice
|
||||
- Durable `turn/end` error messages include cause detail. Existing snapshot fixtures replay byte-identically because their scripted errors carry no `cause` (for such errors `errorChain(err)` equals `err.message`); only unit-test expectation strings changed. A fixture recorded from a real transport failure would carry the chain.
|
||||
- `errorChain` renders `message` without the class name (`String(error)` rendered `Error: <message>`), so a bare `TypeError` in a log line loses its type label unless its message is empty (then the name is the fallback). The chain detail was judged worth more than the class name at these seams.
|
||||
- `dsh-stdio` output for failed turns is no longer silent; piped consumers that parsed the transcript see new `[turn …]` lines.
|
||||
- Remaining `renderThrown` copies in `dsh-subagent`, `dsh-workflow`, `dsh-skill`, `dsh-workflow-workerthread`, and `cli-demo` still render without the chain; they wrap package-local errors that carry their own messages, and can adopt `errorChain` when their diagnostics prove insufficient.
|
||||
- Remaining `renderThrown` copies in `dsh-subagent`, `dsh-workflow`, `dsh-skill`, and `dsh-workflow-workerthread` still render without the chain; they wrap package-local errors that carry their own messages, and can adopt `errorChain` when their diagnostics prove insufficient.
|
||||
|
||||
@@ -34,4 +34,4 @@ TUI 连接不可达的 DeepSeek 端点时,失败只显示一条 `fetch failed`
|
||||
- 持久化的 `turn/end` 错误消息包含 cause 细节。现有快照 fixture(测试前置数据)字节级一致地回放,因为其脚本化错误不带 `cause`(对这类错误 `errorChain(err)` 等于 `err.message`);只有单元测试的期望字符串有变化。从真实传输失败录制的 fixture 会携带完整链。
|
||||
- `errorChain` 渲染 `message` 而不带类名(`String(error)` 会渲染 `Error: <message>`),因此日志行里的裸 `TypeError` 会丢失类型标签,除非消息为空(此时回退到类名)。在这些诊断界面上,链细节被判断为比类名更有价值。
|
||||
- `dsh-stdio` 对失败轮次的输出不再沉默;解析 transcript 的管道消费方会看到新的 `[turn …]` 行。
|
||||
- `dsh-subagent`、`dsh-workflow`、`dsh-skill`、`dsh-workflow-workerthread`、`cli-demo` 里剩余的 `renderThrown` 副本仍不渲染链;它们包装的是自带消息的包内错误,等诊断信息证明不足时再采用 `errorChain`。
|
||||
- `dsh-subagent`、`dsh-workflow`、`dsh-skill`、`dsh-workflow-workerthread` 里剩余的 `renderThrown` 副本仍不渲染链;它们包装的是自带消息的包内错误,等诊断信息证明不足时再采用 `errorChain`。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md
|
||||
2026-07-21-compaction-summary-prefix-cache-reuse.md: d05d25cfa7c3984ce0ce75c38068a91a0e07dfe8
|
||||
2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: f31c33e680b3db60a6fb758a522addca63224fea
|
||||
2026-07-21-compaction-summary-prefix-cache-reuse.md: fb6524846f837b783423b3ed0e043cd4e1f7849d
|
||||
2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: e6072313b7089b9dda14c298fcb94db786220501
|
||||
|
||||
@@ -29,7 +29,7 @@ Auto-compaction always anchors at the surface head, so the shadowed region is th
|
||||
- **Keep the summarizer system prompt but reuse the rest** — rejected: the system slot is the very first token region a provider caches on, so a distinct summarizer system prompt invalidates the whole prefix regardless of what follows. Only moving the directive off the front recovers the cache.
|
||||
- **Send only the shadowed region without the `system`/`tools` head** — rejected: a differently-headed sequence still diverges from the cached request at the first token, so it caches no better while losing the framing the summary needs.
|
||||
- **Omit `tools` from the summarization request** (the model never calls one) — rejected: tool schemas are part of the cached token sequence; omitting them misaligns every following token and defeats reuse.
|
||||
- **A dedicated `assistant/chunk`-emitting summarization sub-session for snapshot replay** — out of scope here; the replay gap predates this change and is tracked in the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md).
|
||||
- **A dedicated `assistant/chunk`-emitting summarization sub-session for snapshot replay** — rejected: the durable `compact/summary` event records the successful local call's position and complete output, while its explicit call marker prevents replay from treating template or remote output as a local stream.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -42,4 +42,4 @@ Auto-compaction always anchors at the surface head, so the shadowed region is th
|
||||
|
||||
- **Unit:** `compact-basic.spec.ts` asserts the auxiliary call forwards `system`/`tools`/leading messages and appends the compaction instruction as the final message, and that `compactRegion` replays the latest routed header prefix. Existing content assertions read the summarizer input through the replayed messages rather than a transcript string.
|
||||
- **Loop:** `compact-loop-repro.spec.ts` classifies the summarization request by the compaction instruction in its trailing user message, and the overflow-recovery tests continue to pin conversation-vs-summary request counts across the real loop.
|
||||
- **Snapshot gap unchanged:** the summarization call still emits no `assistant/chunk` events, so it remains outside keyless replay; the pre-existing gap is owned by the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md).
|
||||
- **Snapshot:** keyless replay reconstructs one canonical successful stream from a marked `compact/summary`; the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md) owns the durable marker contract.
|
||||
|
||||
@@ -29,7 +29,7 @@ Status: implemented
|
||||
- **保留摘要器系统提示词但复用其余部分**——否决:system 槽位正是提供方最先做缓存的 token 区域,因此一个不同的摘要器系统提示词无论后面跟着什么都会使整个前缀失效。只有把指令移离前端才能恢复缓存。
|
||||
- **只发送被遮蔽区域而不带 `system`/`tools` 头部**——否决:头部不同的序列在第一个 token 处仍然与已缓存请求分叉,因此缓存效果并不更好,反而丢失了摘要所需的框架。
|
||||
- **从摘要请求中省略 `tools`**(模型从不调用任何工具)——否决:工具 schema 是已缓存 token 序列的一部分;省略它们会让后续每个 token 失去对齐,破坏复用。
|
||||
- **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——此处超出范围;该回放缺口早于本次改动,记录在 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 中。
|
||||
- **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——否决:持久的 `compact/summary` 事件会记录成功本地调用的位置和完整输出,而显式调用标记可防止回放把模板或远程输出当作本地流。
|
||||
|
||||
## 后果
|
||||
|
||||
@@ -42,4 +42,4 @@ Status: implemented
|
||||
|
||||
- **单元:** `compact-basic.spec.ts` 断言辅助调用转发 `system`/`tools`/前导消息,并把压缩指令作为最后一条消息追加,且 `compactRegion` 回放最新的已路由 header 前缀。现有的内容断言通过回放的消息而非 transcript 字符串来读取摘要器输入。
|
||||
- **循环:** `compact-loop-repro.spec.ts` 依据摘要请求尾部 user 消息中的压缩指令对其分类,溢出恢复测试则继续在真实循环中固定对话请求与摘要请求的数量。
|
||||
- **快照缺口未变:** 摘要调用仍然不发出 `assistant/chunk` 事件,因此它仍处于无密钥回放之外;这一既有缺口归 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 所有。
|
||||
- **快照:** 无密钥回放会从带标记的 `compact/summary` 重建一条规范成功流;[compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 负责持久标记契约。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md
|
||||
2026-07-27-stable-snapshot-refresh-volatiles.md: e2e951cd9f78b319a701a3e60afba48786633f03
|
||||
2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 8ba35f4bbc2a4b408a01e11f09bd767860964875
|
||||
2026-07-27-stable-snapshot-refresh-volatiles.md: c3eeeca01a7820b5f410bd895de998e944e58eb2
|
||||
2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 388b67c67052074fa7423eae294e00b4122b2fe8
|
||||
|
||||
@@ -8,9 +8,13 @@ English | [中文](2026-07-27-stable-snapshot-refresh-volatiles.zh.md)
|
||||
|
||||
ACP snapshot comparison normalizes generated UUIDs, cwd aliases, spill locators, embedded event times, and omitted-byte counts, but refresh write-back persisted the fresh raw values. A behaviorally unchanged refresh therefore rewrote fixtures with new randomness or host-specific path spellings even though the comparison contract considered both logs equal.
|
||||
|
||||
Message identity needs a weaker structural precondition than aligned records: an unrelated log event can break record alignment while an inherited message's identity-free value remains unchanged across parent and child logs. Record mode also begins with freshly minted message UUIDs when it replaces an existing fixture.
|
||||
|
||||
## Decision
|
||||
|
||||
Refresh write-back uses `normalizeSessionLog` as its sole volatile-value authority. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements affect only the raw values being written. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value.
|
||||
Before record or refresh writes session fixtures, the shared snapshot support passes fixture-ready logs to one structural message-ID owner. It recognizes surface carriers through the session package's authoritative surface-type predicate and the correlated queued copies in `agent/inbox/spliced`, fingerprints every complete message with its top-level `id` removed, and records every ID-to-fingerprint edge across all parent/child logs. It reuses an existing UUID only when both its ID and fingerprint have degree one in the fresh and existing graphs, then rewrites only validated message `id` fields in those carriers. Repeated inherited occurrences with the same ID remain one candidate, while new, changed, duplicate-content, malformed, and conflicting messages keep their fresh IDs. ACP, JSON-RPC, and Web recorders run this pass after header scrubbing and cwd tokenization, so fixture spellings rather than raw host paths determine identity.
|
||||
|
||||
Refresh write-back uses `normalizeSessionLog` as its volatile-value authority for aligned leaves. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements are limited to fresh-run session IDs, cwd values, and spill paths. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value. Complete message IDs in surface or inbox carriers are excluded from this path so positional reuse and structural reuse cannot assign the same committed UUID independently.
|
||||
|
||||
Before reuse, the complete logical-record layout must align, apart from the existing packed-chunk and inserted-title equivalences. Normalized-equivalent changed strings form a log-wide bijection: one fresh string maps to exactly one existing string and vice versa, so repeated IDs remain correlated across records. An unexplained record mismatch or conflicting mapping disables normalized string reuse for that log.
|
||||
|
||||
@@ -26,6 +30,6 @@ Object fields align by key. Array elements align only when all corresponding arr
|
||||
|
||||
## Consequences
|
||||
|
||||
Repeated refreshes no longer rewrite aligned fixture values solely because the normalizer classifies them as volatile, and new volatile categories added to the normalizer automatically inherit the write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, and strings containing both semantic and volatile changes use fresh values rather than risk reusing misaligned data.
|
||||
Record and refresh no longer rewrite an unchanged unique message UUID solely because another event changed the surrounding record layout, regardless of whether ACP, JSON-RPC, or Web owns the recording. Repeated refreshes also retain aligned fixture values that the normalizer classifies as volatile, and new volatile categories added to the normalizer automatically inherit that write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, strings containing both semantic and volatile changes, malformed messages, and any message graph with a non-unique ID or fingerprint use fresh values rather than risk reusing misaligned data.
|
||||
|
||||
Focused unit coverage pins recursive object/array behavior, correlated IDs, ambiguous-layout fallback, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical.
|
||||
Focused unit coverage pins all authoritative surface-message shapes, durable inbox/surface correlation, scenario-wide parent/child correlation, cwd-bearing fixture-ready matching, unrelated event insertion, malformed-message isolation, both-axis graph ambiguity, single-owner write-back, recursive object/array behavior, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical.
|
||||
|
||||
@@ -8,9 +8,13 @@ Status: implemented
|
||||
|
||||
ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别名、spill locator、嵌入的事件时间和省略字节数,但刷新写回会持久化本次生成的原始值。因此,即使比较契约将两份日志视为相等,一次行为未发生变化的刷新仍会用新的随机值或宿主特有的路径写法改写 fixture(测试前置数据)。
|
||||
|
||||
消息身份所需的结构前提比记录对齐更弱:无关的日志事件可能破坏记录对齐,但继承而来的消息去除身份后的值在父级和子级日志之间仍保持不变。录制模式在替换现有 fixture 时也会从新生成的消息 UUID 开始。
|
||||
|
||||
## 决策
|
||||
|
||||
刷新写回以 `normalizeSessionLog` 作为易变值的唯一判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture 头部上下文归一化 fixture 记录;字面量替换只影响要写入的原始值。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。
|
||||
在录制或刷新写入会话 fixture 前,共享快照支持层会将可写入 fixture 的日志交给一个负责结构化处理消息 ID 的组件。该组件通过会话包的权威 surface 类型谓词识别 surface 载体,并识别 `agent/inbox/spliced` 中与这些载体关联的已排队消息副本;随后移除每条完整消息的顶层 `id` 并计算指纹,同时记录所有父级/子级日志中每条 ID 与指纹之间的关联边。仅当该 ID 与指纹在本次生成图和现有图中的度均为 1 时,才会复用现有 UUID,随后仅改写这些载体中通过验证的消息 `id` 字段。具有相同 ID、重复出现的继承消息仍算作一个候选项;新增、发生变化、内容重复、格式错误和存在冲突的消息则保留本次生成的 ID。ACP、JSON-RPC 和 Web 录制器会在擦除 header 并对 cwd 进行 token 化后执行这一步,因此消息身份取决于 fixture 中的写法,而非宿主机原始路径。
|
||||
|
||||
刷新写回以 `normalizeSessionLog` 作为已对齐叶值的易变值判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture 头部上下文归一化 fixture 记录;字面量替换仅限于本次运行生成的会话 ID、cwd 值和 spill 路径。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。surface 或 inbox 载体中的完整消息 ID 不参与这一路径,以免按位置复用与结构复用各自独立分配同一个已提交 UUID。
|
||||
|
||||
复用前必须确保完整逻辑记录布局对齐,现有的打包分片与插入标题等价情形除外。归一化后等价但发生变化的字符串在整份日志范围内形成双射:一个本次生成的字符串只映射到一个现有字符串,反向亦然,因此跨记录重复出现的 ID 仍保持关联。出现无法解释的记录不匹配或映射冲突时,该日志会停用归一化字符串复用。
|
||||
|
||||
@@ -26,6 +30,6 @@ ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别
|
||||
|
||||
## 后果
|
||||
|
||||
重复刷新不再仅仅因为规范化器将已对齐的 fixture 值归类为易变值,就改写这些值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化,或字符串同时包含语义变化与易变变化时,均使用本次生成的值,避免冒险复用未对齐的数据。
|
||||
录制和刷新不再仅仅因为另一个事件改变了周边记录布局,就改写未变化且唯一的消息 UUID,无论该录制由 ACP、JSON-RPC 还是 Web 负责。重复刷新也会保留规范化器归类为易变值的已对齐 fixture 值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化、字符串同时包含语义变化与易变变化、消息格式错误,或消息图中的 ID 或指纹不唯一时,均使用本次生成的值,避免冒险复用未对齐的数据。
|
||||
|
||||
聚焦的单元测试固定了递归处理对象与数组的行为、关联 ID、有歧义布局时的回退、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。
|
||||
聚焦的单元测试固定了会话包权威谓词识别的所有 surface 消息形态、持久 inbox/surface 关联、场景范围内的父级/子级消息关联、带 cwd 的可写入 fixture 消息匹配、无关事件插入、格式错误消息隔离、消息图在 ID 与指纹两条轴上的歧义、由单一处理方负责的写回、递归处理对象与数组的行为、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md
|
||||
2026-07-29-pnpm-setup-runner-isolation.md: 743535d0394cbea0374c412ba6968910ce858de4
|
||||
2026-07-29-pnpm-setup-runner-isolation.zh.md: 1e51070f88dead17b9d3f5625e337c558786aba2
|
||||
2026-07-29-pnpm-setup-runner-isolation.md: 74b672b3f90ea445ad1a8e283a5904056059b2f8
|
||||
2026-07-29-pnpm-setup-runner-isolation.zh.md: 32c667dc09e561504e8e053bf2a338ed2190d9e8
|
||||
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`pnpm/action-setup@v4` defaults its install destination to `~/setup-pnpm` and replaces that directory during setup. The self-hosted CI failover runs six GitHub Actions runner services under one VM user, so concurrent jobs shared the same destination. In [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773), three jobs entered pnpm setup within 73 milliseconds; one setup removed another process's current working directory and two jobs failed in Node's `uv_cwd` initialization. A retry on another runner passed, making the failure timing-dependent rather than a repository-test regression.
|
||||
`pnpm/action-setup@v4` defaults its install destination to `~/setup-pnpm` and replaces that directory during setup. The self-hosted CI failover runs six GitHub Actions runner services under one VM user, so concurrent jobs shared the same destination. In the reproducing run, three jobs entered pnpm setup within 73 milliseconds; one setup removed another process's current working directory and two jobs failed in Node's `uv_cwd` initialization. A retry on another runner passed, making the failure timing-dependent rather than a repository-test regression.
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
`pnpm/action-setup@v4` 的安装目标目录默认为 `~/setup-pnpm`,并会在设置期间替换该目录。自托管 CI 故障切换在同一个 VM 用户下运行六个 GitHub Actions runner 服务,因此并发作业会共用同一目标目录。在 [run 30375670773](https://github.com/deepseek-harness/deepseek-harness/actions/runs/30375670773) 中,三个作业在 73 毫秒内进入 pnpm 设置;其中一个设置过程删除了另一个进程的当前工作目录,导致两个作业在 Node 的 `uv_cwd` 初始化阶段失败。换到另一台 runner 重试后通过,说明该故障取决于时序,并非仓库测试回归。
|
||||
`pnpm/action-setup@v4` 的安装目标目录默认为 `~/setup-pnpm`,并会在设置期间替换该目录。自托管 CI 故障切换在同一个 VM 用户下运行六个 GitHub Actions runner 服务,因此并发作业会共用同一目标目录。在复现运行中,三个作业在 73 毫秒内进入 pnpm 设置;其中一个设置过程删除了另一个进程的当前工作目录,导致两个作业在 Node 的 `uv_cwd` 初始化阶段失败。换到另一台 runner 重试后通过,说明该故障取决于时序,并非仓库测试回归。
|
||||
|
||||
## 决策
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md
|
||||
2026-07-30-web-transcript-log-ordered-projection.md: 558d60cf6f6638c3e776396dd754d31b95819b28
|
||||
2026-07-30-web-transcript-log-ordered-projection.zh.md: 2eb216fd1970700fb54a2aa17ec5ce515869e5b0
|
||||
2026-07-30-web-transcript-log-ordered-projection.md: 3b7aaeb1178ff79e38a1b9646a9dc78efaeae48b
|
||||
2026-07-30-web-transcript-log-ordered-projection.zh.md: acd198b6d3f3c5d57e233e09ee66f5d151e4f8f1
|
||||
|
||||
@@ -18,9 +18,11 @@ Node order is seq-monotonic by construction, and three things follow. The log-on
|
||||
|
||||
`foldDegraded` is gone from `ConversationSnapshot`, and with it the padding sentinels, the `baseSeq` arithmetic they needed, and `degradedSeqs()`. They existed only to satisfy the core fold's `seq === index` assertion and to survive its throw; the fold they describe is no longer run. Deleting the flag is part of the fix, not cleanup after it — `degradedSeqs()` was already almost the log-ordered projection, reached after a thrown error instead of intended.
|
||||
|
||||
The marker's summary text comes from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes the row non-expandable rather than empty, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves the text.
|
||||
The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's own `compact/summary` provenance, never from the framed checkpoint payload, which is an instruction envelope written for the model. A window cut that left the provenance outside makes those fields unavailable, the same soft-fall as a call-less tool result, and a later page supplying the provenance resolves them.
|
||||
|
||||
No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required.
|
||||
The [manual compaction command](../feature/2026-07-30-queued-manual-compaction.md) returns the summary event's seq as the successful `CommandResult.sourceEventSeq`, and `command/done` persists that optional reference. Chat pairs only a successful named `/compact` command whose reference equals exactly one loaded `CompactionSummaryNode.summaryEventSeq`. The running command first renders `compact · Compacting context…`; after the checkpoint lands, the same React key renders one collapsed `compact` disclosure at the checkpoint's flow position with the count and token estimate. Input rejection, no compactable history, cancellation, and failure remain generic command rows with complete handler-authored text. Automatic compaction has no command reference and keeps the standalone context-compacted marker.
|
||||
|
||||
The explicit event reference matters because manual compaction permits durable context injection while its asynchronous summary is running: command and checkpoint rows are not guaranteed to be adjacent. The command lifecycle event gains one optional field, but the compaction transaction, RPC envelope, and model-visible surface do not change; pre-release persisted logs without the field keep the former two-row soft-fall and require no migration.
|
||||
|
||||
## Recognizing a checkpoint: one declaration, pinned at compile time
|
||||
|
||||
@@ -57,6 +59,10 @@ The unmerged manual-compaction-queueing branch fixes the same interleaving bug b
|
||||
|
||||
**Keep `foldDegraded` as a defensive flag.** Rejected: it described a specific failure of a fold that no longer runs. A flag no consumer can act on, reachable only through a `console.error`, is a false contract.
|
||||
|
||||
**Pair the nearest `/compact` row with the next checkpoint.** Rejected: context injection may land between them, and concurrent or malformed lifecycle records must degrade without stealing another checkpoint. The command result instead names the authoritative summary event, and ambiguous references pair nothing.
|
||||
|
||||
**Parse the English settlement text for item and token counts.** Rejected: handler copy is presentation text, not a stable data contract. The marker reads the structured `compact/summary` payload already owning both values.
|
||||
|
||||
## Consequences
|
||||
|
||||
Compaction no longer erases web history; a session compacted several times shows one marker per landed compaction, in log order, and the same window renders identically live and after a cold resume. The pagination hole is closed by construction rather than defended against, and `ConversationSnapshot` loses a published field, which touched thirteen files.
|
||||
@@ -65,8 +71,8 @@ Compaction no longer erases web history; a session compacted several times shows
|
||||
|
||||
The performance contract is unchanged and now simpler to state: one append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not even recomputed — and unchanged nodes keep their object identity. The window still grows with session length rather than with the surface, which is the trade the fix exists to make; a compaction used to bound the projection for exactly the long sessions compaction serves.
|
||||
|
||||
The web e2e scenario now seeds a real compaction transaction over its recorded turn, so the aria golden pins both halves of the fix through the real host and a real browser: the recorded prompt and full tool output are still on screen, and one marker sits after them. The seed recording itself is untouched and stays model-authentic — replay derives the compacted turn from the recording's own surface.
|
||||
The web e2e scenario now seeds a real manual command lifecycle around a compaction transaction over its recorded turn, so the aria golden pins the complete behavior through the real host and a real browser: the recorded prompt and full tool output are still on screen, exactly one `compact` row reports scale after them, and its disclosure opens the exact summary. The seed recording itself is untouched and stays model-authentic — replay derives the manual compaction from the recording's own surface.
|
||||
|
||||
## Deferred
|
||||
|
||||
The terminal's [archived compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses the live standalone bracket to drive a one-cell indicator and does not change this browser projection. The marker still carries no **scale**: the checkpoint's `sourceEventSeqs` hold the shadowed count, so a separately justified count or range can be added without coupling it to progress.
|
||||
The terminal's [archived compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses the live standalone bracket to drive a one-cell indicator and does not change this browser projection.
|
||||
|
||||
@@ -18,9 +18,11 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它
|
||||
|
||||
`foldDegraded` 从 `ConversationSnapshot` 消失,随之消失的是哨兵填充、它们所需的 `baseSeq` 算术,以及 `degradedSeqs()`。它们的存在只为满足核心 fold 的 `seq === index` 断言并在其抛错时存活;它们所描述的 fold 已不再运行。删除该标志是修复的一部分,而非修复之后的清理——`degradedSeqs()` 本身已几乎就是按日志顺序的投影,只是作为抛错后的落点而非本意到达。
|
||||
|
||||
标记的摘要文本来自检查点自己的 `compact/summary` 溯源,绝不取自成框的检查点载荷——那是为模型撰写的指令信封。窗口切分把溯源留在窗口外时该行不可展开而非空白,与无调用的工具结果同一种软退让;后续补上溯源的分页会解析出文本。
|
||||
标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点自己的 `compact/summary` 溯源,绝不取自成框的检查点载荷——那是为模型撰写的指令信封。窗口切分把溯源留在窗口外时这些字段不可用,与无调用的工具结果同一种软退让;后续补上溯源的分页会解析出它们。
|
||||
|
||||
没有任何持久化事件、RPC 信封、压缩事务或模型可见 surface 发生变化,也不需要迁移。
|
||||
[手动压缩命令](../feature/2026-07-30-queued-manual-compaction.md)会把摘要事件的 seq 作为成功结果的 `CommandResult.sourceEventSeq` 返回,`command/done` 则持久化这项可选引用。Chat 只会配对成功且名称为 `/compact`、其引用恰好等于唯一一个已加载 `CompactionSummaryNode.summaryEventSeq` 的命令。运行中的命令先渲染为 `compact · Compacting context…`;检查点落地后,同一个 React key 会在检查点的消息流位置渲染一条收起的 `compact` 展开项,并显示条目数量和 token 估算值。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行,并保留处理器撰写的完整文本。自动压缩没有命令引用,继续使用独立的上下文已压缩标记。
|
||||
|
||||
显式事件引用之所以重要,是因为手动压缩允许在异步摘要运行期间注入持久上下文:命令行与检查点行不保证相邻。命令生命周期事件增加一个可选字段,但压缩事务、RPC 信封和模型可见 surface 均不变化;不含该字段的预发布持久日志继续采用原先的两行软退让,无须迁移。
|
||||
|
||||
## 识别检查点:同一份声明,在编译期钉住
|
||||
|
||||
@@ -57,6 +59,10 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
|
||||
|
||||
**把 `foldDegraded` 留作一个防御性标志。** 已拒绝:它描述的是一个已不再运行的 fold 的特定失败。一个消费方无法据以行动、只能通过 `console.error` 到达的标志,是一份虚假契约。
|
||||
|
||||
**把最近的 `/compact` 行与下一个检查点配对。** 已拒绝:两者之间可能落入上下文注入,并发或格式异常的生命周期记录也必须降级而不误取其他检查点。命令结果则指明权威摘要事件;引用存在歧义时不配对任何内容。
|
||||
|
||||
**解析英文结算文本中的条目数量和 token 数量。** 已拒绝:处理器文案是呈现文本,而非稳定的数据契约。标记读取本已持有这两个值的结构化 `compact/summary` 载荷。
|
||||
|
||||
## Consequences
|
||||
|
||||
压缩不再抹掉 Web 历史;一个被压缩多次的会话按日志顺序显示每次落地压缩一个标记,而同一窗口在实时与冷恢复之后渲染完全相同。分页缺口是被构造性闭合而非被防御,`ConversationSnapshot` 少了一个已发布字段,这触及十三个文件。
|
||||
@@ -65,8 +71,8 @@ const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact'
|
||||
|
||||
性能契约未变,且现在更易表述:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用——因此分片风暴零成本、`nodes()` 甚至不会重算——未变化的节点保持其对象标识。窗口仍随会话长度而非随 surface 增长,这正是本修复存在所要做的交换;一次压缩过去恰好为压缩所服务的长会话限制了投影规模。
|
||||
|
||||
Web e2e 场景现在在它录制的那一轮之上播种一次真实的压缩事务,因此 aria 基准经真实宿主与真实浏览器钉住修复的两半:录制的提问与完整工具输出仍在屏幕上,其后坐着一个标记。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出被压缩的那一轮。
|
||||
Web e2e 场景现在围绕它录制的那一轮上的压缩事务播种一次真实的手动命令生命周期,因此 aria 基准经真实宿主与真实浏览器钉住完整行为:录制的提问与完整工具输出仍在屏幕上,其后恰好一条 `compact` 行报告规模,展开后会显示确切摘要。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出手动压缩。
|
||||
|
||||
## Deferred
|
||||
|
||||
终端的[已归档压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动单格指示器,并不改变此浏览器投影。标记仍不携带**规模**信息:检查点的 `sourceEventSeqs` 保存被遮蔽的数量,因此可以另行论证后添加计数或区间,而无须将其与进度耦合。
|
||||
终端的[已归档压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用实时独立标记对驱动单格指示器,并不改变此浏览器投影。
|
||||
|
||||
@@ -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-01-ask-user-delegated-caller-guard.md
|
||||
2026-08-01-ask-user-delegated-caller-guard.md: 0350a7383f7ace6ecd742c98db18f8b977720cf9
|
||||
2026-08-01-ask-user-delegated-caller-guard.zh.md: 9c19722feb4586e07845fd20279574301267542d
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: Reject human interaction from runtime-owned subagents
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-01-ask-user-delegated-caller-guard.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A one-shot subagent that calls `ask_user_question` can block indefinitely. The call waits for a human answer, but the child has no independently owned human channel, so the child's completion and the parent waiting on that completion both stall.
|
||||
|
||||
Durable session lineage cannot decide whether an answerer exists. A child session may later be resumed as a new top-level runtime root, while a live runtime-owned child may carry a zero or absent durable delegation depth. Error guidance at the shared seam must also fit every consumer: `exit_plan_mode` uses `ctx.userInteraction.ask()` without calling `ask_user_question`.
|
||||
|
||||
## Decision
|
||||
|
||||
When `AskUserQuestionRequest.agent` is present, `UserInteractionService.ask()` authenticates the exact live agent through `ctx.agents` and admits it only when `ctx.agents.roots()` contains that instance. A missing registry or stale same-id object fails with `CALLER_NOT_LIVE`; a live agent owned by another live agent fails with `DELEGATED_CALLER`. The check runs after the existing aborted and empty-batch guards and before intent validation or provider dispatch, so an owned child never creates a UI wait.
|
||||
|
||||
Runtime ownership is the authority. A lineage-bearing session resumed without an owner is a runtime root and may ask; a live child remains ineligible even when its durable `delegationDepth` is zero. Agentless programmatic calls retain the existing provider path.
|
||||
|
||||
The shared failure text is consumer-neutral and actionable: the child includes the unresolved question or decision in its final result. The parent already receives that result through the delegation contract and can decide whether to ask the human. Neither the service nor a child claims an upward messaging or answer-forwarding capability that does not exist.
|
||||
|
||||
This safety boundary is independent of the browser's composer election. The proposed [semantic composer phases](../../proposed/architecture/2026-08-08-semantic-composer-chain-phases.md) address how an already-pending interaction and a read-only subagent surface should be ordered; they do not weaken this runtime guard.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Use `session.header.delegationDepth > 0`.** Rejected because durable lineage survives resume and does not attest the current process-local owner. It rejects valid resumed roots and can admit a live child whose durable header is incomplete.
|
||||
|
||||
**Reject only inside `dsh-tool-ask-user`.** Rejected because `exit_plan_mode` and direct callers share `ctx.userInteraction.ask()`. The service is the narrow operation boundary common to every human-interaction consumer.
|
||||
|
||||
**Tell the child to delegate upward or wait for forwarding.** Rejected because one-shot delegation exposes no child-to-parent request channel and no answer-forwarding protocol. The only guaranteed return path is the child's final result.
|
||||
|
||||
**Rely on the browser composer fix.** Rejected because presentation cannot make an ownerless human channel exist, and non-browser deployments still need the call to terminate.
|
||||
|
||||
## Consequences
|
||||
|
||||
Runtime-owned child calls fail fast with a stable structured error instead of hanging. Exact live roots and agentless programmatic calls remain eligible, including resumed sessions with historical child lineage. `ask_user_question` and `exit_plan_mode` receive the same neutral corrective guidance, while their model-visible schemas and system-prompt prefixes remain unchanged; only the appended error result differs, so existing KV-cache prefixes remain reusable.
|
||||
|
||||
## Testing
|
||||
|
||||
Service tests cover a zero-depth live child, a depth-one resumed runtime root, a missing registry, a stale same-id object, and provider non-invocation on every rejection. Tool and plan-mode tests prove both consumers surface the neutral `DELEGATED_CALLER` result and never reach the provider. The keyless assembled snapshot delegates to a child that attempts `ask_user_question`, pins the child's error tool result and final handoff, and proves the parent completes instead of waiting for an answer.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent Note: 拒绝运行时中归属于其他 agent 的 subagent 向人类发起交互
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-01-ask-user-delegated-caller-guard.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
一次性 subagent 调用 `ask_user_question` 时可能无限阻塞。该调用会等待人类回答,但子级没有由自身独立拥有的人类交互通道,因此子级无法完成,等待其完成的父级也会随之停滞。
|
||||
|
||||
持久化会话谱系无法判断应答者是否存在。子会话之后可能恢复为新的顶层运行时根,而运行时中归属于其他 agent(智能体)的存活子级,其持久化委托深度却可能为零或缺失。共享 seam 上的错误指引还必须适用于每个消费方:`exit_plan_mode` 会使用 `ctx.userInteraction.ask()`,但不会调用 `ask_user_question`。
|
||||
|
||||
## 决策
|
||||
|
||||
如果存在 `AskUserQuestionRequest.agent`,`UserInteractionService.ask()` 会通过 `ctx.agents` 验证该 agent 就是注册表中的存活实例,并且只在 `ctx.agents.roots()` 包含该实例时才允许调用。缺失注册表或传入仅 id 相同的陈旧对象时,以 `CALLER_NOT_LIVE` 失败;存活 agent 归属于另一个存活 agent 时,以 `DELEGATED_CALLER` 失败。该检查位于现有的已中止和空批次守卫之后、意图校验或提供方分派之前,因此归属于其他 agent 的子级绝不会触发 UI 等待。
|
||||
|
||||
以运行时所有权为权限依据。携带谱系的会话在无所有者的情况下恢复时就是运行时根,可以提问;存活子级即使持久化 `delegationDepth` 为零,仍无资格提问。不带 agent 的程序化调用继续沿用现有提供方路径。
|
||||
|
||||
共享失败文本与具体消费方无关,并给出可执行指引:子级把尚未解决的问题或决策写入最终结果。委托契约本就会把该结果传给父级,父级可据此决定是否询问人类。服务和子级都不会宣称存在实际上并不存在的向上消息传递或回答转发能力。
|
||||
|
||||
该安全边界与浏览器的 composer 选举相互独立。提议的[语义 composer 阶段](../../proposed/architecture/2026-08-08-semantic-composer-chain-phases.md)解决已有待处理交互与只读 subagent 界面的排序方式;它不会削弱此运行时守卫。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**使用 `session.header.delegationDepth > 0`。** 不予采用:持久化谱系会在恢复后继续存在,却不能证明当前进程内所有者。该方案会拒绝有效的已恢复根,也可能放行持久化 header 不完整的存活子级。
|
||||
|
||||
**仅在 `dsh-tool-ask-user` 内拒绝。** 不予采用:`exit_plan_mode` 与直接调用方共用 `ctx.userInteraction.ask()`。服务是所有人机交互消费方共同经过的最窄操作边界。
|
||||
|
||||
**让子级向上委托或等待转发。** 不予采用:一次性委托没有公开从子级向父级请求的通道,也没有回答转发协议。唯一有保证的返回路径是子级的最终结果。
|
||||
|
||||
**依赖浏览器的 composer 修复。** 不予采用:呈现方式无法凭空产生由所有者负责的人类通道,非浏览器部署仍然需要该调用能够终止。
|
||||
|
||||
## 影响
|
||||
|
||||
运行时中归属于其他 agent 的子级调用会以稳定的结构化错误快速失败,而不是挂起。注册表中的确切存活根和不带 agent 的程序化调用仍有资格提问,包括带有历史子级谱系的已恢复会话。`ask_user_question` 与 `exit_plan_mode` 会收到相同的中性纠正指引,而其模型可见 schema 和系统提示词前缀保持不变;只有追加的错误结果发生变化,因此现有 KV Cache 前缀仍可复用。
|
||||
|
||||
## Testing
|
||||
|
||||
服务测试覆盖持久化深度为零的存活子级、深度为一的已恢复运行时根、缺失注册表、仅 id 相同的陈旧对象,以及每次拒绝都不调用提供方。工具与 plan-mode 测试证明两个消费方都会呈现中性的 `DELEGATED_CALLER` 结果,且绝不触达提供方。无密钥组装快照委托一个尝试调用 `ask_user_question` 的子级,固定其错误工具结果和最终交接,并证明父级可以完成,而不是一直等待回答。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-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
|
||||
2026-08-03-cli-signal-shutdown-escalation.md: 7c9715c37ee57be9fa0f67af0c19f0bfa84845da
|
||||
2026-08-03-cli-signal-shutdown-escalation.zh.md: f3485edc9e453c0b774f442bfce6d678d63f2224
|
||||
|
||||
@@ -6,9 +6,9 @@ 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.
|
||||
The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and the headless command (now `dsh run`) 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.
|
||||
A user then reproduced the headless command 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.
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
## 问题
|
||||
|
||||
默认挂载遥测后,`dsh web` 与 `dsh -p` 新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。
|
||||
默认挂载遥测后,`dsh web` 与 headless 命令(现为 `dsh run`)新增了 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 超时,也会让提供方关闭一直待结算。
|
||||
随后有用户复现,headless 命令在打印观察 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 使用的闩锁结构与此相同。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md
|
||||
2026-08-06-api-key-format-validation.md: d1f6d31d362b76392514704be780f553b45d36ad
|
||||
2026-08-06-api-key-format-validation.zh.md: 75b3fa247bdf449964a874e909e6e3bc9e0694fa
|
||||
2026-08-06-api-key-format-validation.md: 2174cb466c6af72f15005ce1ba3dec8100de6f2f
|
||||
2026-08-06-api-key-format-validation.zh.md: f9b7d6518fedc42e5264c46beaa1a78619139c58
|
||||
|
||||
@@ -12,15 +12,13 @@ Pasting a key containing an emoji, CJK text, or a full-width punctuation mark in
|
||||
|
||||
`llm-pi-ai` was worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wrapped every failure as `could not reach <url>`, so a local key fault was reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sent an illegal key before anything was stored.
|
||||
|
||||
Whitespace passed every check. `ProviderEditor` tested `keyDraft.length` and `resolveAdapterOptions` tested `config.apiKey.length`, so a key of three spaces stored and then authenticated as `Bearer` plus blanks. `llm-pi-ai` rejected an empty literal `apiKey` in `resolveProfiles`, but applied no check whatsoever to a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take.
|
||||
|
||||
Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210.
|
||||
Whitespace passed every check. `ProviderEditor` tested `keyDraft.length`, so a key of three spaces was stored and then authenticated as `Bearer` plus blanks. Neither adapter checked a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take.
|
||||
|
||||
## Decision
|
||||
|
||||
One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded.
|
||||
|
||||
This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes.
|
||||
This single predicate covers every reported input: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the failures share one definition rather than two coincidentally related fixes.
|
||||
|
||||
A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=[^=]` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen — and requiring a non-`=` character after the separator keeps base64 padding clear of it too. It reports the same format failure as an illegal character rather than its own message: the reader's next move is identical either way, so a separate line would name a cause without changing what to do.
|
||||
|
||||
@@ -32,13 +30,13 @@ The shape rule is a guess about how people paste, so it runs **only in the brows
|
||||
|
||||
### Absence is a configuration state, not a missing key
|
||||
|
||||
"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller.
|
||||
The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller.
|
||||
|
||||
**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` carries this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated.
|
||||
**No named credential.** A pi-ai profile omitting `apiKeyEnv` may authenticate outside the harness-held credential path. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth. `namesCredential` carries this distinction; omission is not a value to validate.
|
||||
|
||||
**A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field never blocks submit, or editing a base URL would demand re-entering the key.
|
||||
|
||||
**Provided, but empty or whitespace-only.** What this means depends on what absence selects for that surface, and the two adapters differ for a reason. In `llm-pi-ai` it is an error, because absence there switches authentication mode — to the installed provider's ambient discovery or OAuth — so a blank key leaves genuine ambiguity about which was meant; its wording names the legitimate alternative rather than just refusing (*has an empty apiKey; omit it to use ambient authentication*). In `llm-deepseek` absence merely selects a different *source* for the same key, `apiKeyEnv`, so a blank literal resolves through that fallback exactly as an omitted one does. In the browser it is always a failure, on both cards: the field is where a person just typed, and silently discarding what they typed is never the right answer.
|
||||
**A resolved value that is whitespace-only.** This is invalid at both adapters because it cannot authenticate a request. In the browser it is also a field-level failure: the field is where a person just typed, and silently discarding what they typed is never the right answer.
|
||||
|
||||
`normalizeApiKey` therefore takes `string`, never `string | undefined`.
|
||||
|
||||
@@ -55,9 +53,7 @@ The client cannot import any of this: client packages reference only client pack
|
||||
| Surface | Behavior |
|
||||
|---|---|
|
||||
| `dsh-llm` | Owns `normalizeApiKey`, `assertUsableApiKey`, and `INVALID_CREDENTIAL_CODE`, which is deliberately outside `DEFAULT_RETRYABLE_CODES`. |
|
||||
| `llm-deepseek` `resolveAdapterOptions` | Refuses a literal `apiKey` no header can carry, beside the other beyond-schema bounds; uses the trimmed value. An absent or blank one falls through to `apiKeyEnv`. |
|
||||
| `llm-deepseek` `resolveApiKey` | Normalizes what the credentials seam or environment returns, rejecting with `INVALID_CREDENTIAL` naming the Models page and never echoing the key. |
|
||||
| `llm-pi-ai` `resolveProfiles` | Applies the shared rule, keeping its "omit it to use ambient authentication" wording, and writes the trimmed value into the resolved profile. |
|
||||
| `llm-pi-ai` `resolveApiKey` | Normalizes the credential and environment paths. A profile naming no credential still returns `undefined`, so ambient and OAuth routes are unaffected. |
|
||||
| `llm-pi-ai` `discoverModels` | Normalizes before building the header, so an illegal key is a credential fault rather than an unreachable endpoint. A probe carrying no key stays unauthenticated. |
|
||||
| `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit **and the endpoint interrogation** are both gated, so a refused key never spends a round trip to be told what the field already says, and the failure renders on the field, matching the existing `modelFailure` pattern. |
|
||||
@@ -68,8 +64,6 @@ The client cannot import any of this: client packages reference only client pack
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It lost because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express.
|
||||
|
||||
**A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here.
|
||||
|
||||
**A per-adapter thrower in each of `llm-deepseek` and `llm-pi-ai`.** The first plan gave each adapter its own, differing only by the package prefix in the message, with a duplication-gate exemption to excuse the pair. Rejected before implementation: `LlmError` is declared in the seam, so the seam can own the diagnosis outright, and an exemption there would have hidden exactly the duplication it was covering for.
|
||||
@@ -80,7 +74,7 @@ The client cannot import any of this: client packages reference only client pack
|
||||
|
||||
**Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open.
|
||||
|
||||
**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on the code as it stood, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verified nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this change makes reliable; building it first would have produced a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call there would be an unexpected behavior rather than a missing one.
|
||||
**Probing the provider at save time to prove the key works.** It would close the original complaint — a save that reports success and fails at the first turn. Rejected as out of scope and, on the code as it stood, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verified nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this change makes reliable; building it first would have produced a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call there would be an unexpected behavior rather than a missing one.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -100,7 +94,7 @@ The costliest way to get this wrong would have been to treat absence as invalidi
|
||||
|
||||
`packages/llm/llm/tests/api-key.spec.ts` drives `normalizeApiKey` and `assertUsableApiKey` over the whole input table — empty, whitespace-only, padded, interior-space, C0 control, emoji, CJK, full-width, latin-1, and the printable-ASCII boundary — and pins that a refusal carries `INVALID_CREDENTIAL` and no part of the key.
|
||||
|
||||
`packages/llm/llm-deepseek/tests/` covers the literal-config path in `adapter.spec.ts` and the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers `resolveProfiles` — including that the trimmed value reaches the resolved profile, which the `...rest` spread would otherwise discard — and the discovery probe, including that a probe with no key sends no `authorization` header.
|
||||
`packages/llm/llm-deepseek/tests/` covers the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers the discovery probe, including that a probe with no key sends no `authorization` header.
|
||||
|
||||
`packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit and the interrogation alike, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all.
|
||||
|
||||
|
||||
@@ -12,15 +12,13 @@ Status: implemented
|
||||
|
||||
同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach <url>`,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。
|
||||
|
||||
空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。
|
||||
|
||||
来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。
|
||||
空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。两个适配器都不检查来自凭据或环境的 Key——而那正是 Models 页写入的路径,也就是用户真正走的路径。
|
||||
|
||||
## Decision
|
||||
|
||||
一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。
|
||||
|
||||
这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。
|
||||
这一个断言覆盖了所有已报告的输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以这些故障收敛于同一个定义,而不是两个恰好相关的修复。
|
||||
|
||||
第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=[^=]` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配——而要求分隔符之后必须是非 `=` 字符,则让 base64 的 padding 也与之绝缘。它报出的是与非法字符相同的那条格式失败,而不是自己的一句:读到它的人下一步动作完全一样,因此单列一句只会点出一个原因,却不改变该怎么做。
|
||||
|
||||
@@ -32,13 +30,13 @@ Status: implemented
|
||||
|
||||
### 「没有 Key」是一种配置状态,不是缺失
|
||||
|
||||
在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。
|
||||
规则作用于*已提供*的值;至于究竟有没有提供,由各个调用方自行判断。
|
||||
|
||||
**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 承载着这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。
|
||||
**未点名凭据。** 省略 `apiKeyEnv` 的 pi-ai profile 可以在 harness 持有的凭据路径之外鉴权。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现继续工作;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权。`namesCredential` 承载这一区分;省略不是需要校验的值。
|
||||
|
||||
**Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时完全跳过 `credentials.set`,这一点保持不变:留空绝不拦截提交,否则改一个 base URL 都得重新输一遍 Key。
|
||||
|
||||
**已提供,但为空或纯空白。** 它意味着什么,取决于「缺失」在该界面上选中了什么,而两个适配器的差异是有依据的。在 `llm-pi-ai` 中它是错误,因为那里的缺失切换的是**鉴权方式**——转向内置 provider 的 ambient 发现或 OAuth——因此一个空 Key 究竟想选哪一种是真有歧义;它的措辞指明了合法替代路径而非单纯拒绝(*has an empty apiKey; omit it to use ambient authentication*)。在 `llm-deepseek` 中,缺失只是为同一把 Key 选择了另一个**来源** `apiKeyEnv`,因此空白字面量会像缺省一样经该回落解析。在浏览器中它始终是失败,两张卡片皆然:字段是人刚刚敲过字的地方,静默丢弃他敲进去的内容永远不是正确答案。
|
||||
**解析得到的值只含空白。** 两个适配器都将其视为非法,因为它无法为请求鉴权。在浏览器中,这同样是字段级失败:字段是人刚刚敲过字的地方,静默丢弃他敲进去的内容永远不是正确答案。
|
||||
|
||||
因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。
|
||||
|
||||
@@ -55,9 +53,7 @@ Status: implemented
|
||||
| 界面 | 行为 |
|
||||
|---|---|
|
||||
| `dsh-llm` | 拥有 `normalizeApiKey`、`assertUsableApiKey` 与 `INVALID_CREDENTIAL_CODE`,后者刻意不进 `DEFAULT_RETRYABLE_CODES`。 |
|
||||
| `llm-deepseek` `resolveAdapterOptions` | 拒绝标头无法承载的字面量 `apiKey`,与其他超出 schema 的边界检查并排;使用 trim 后的值。缺省或空白的 `apiKey` 回落到 `apiKeyEnv`。 |
|
||||
| `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值,以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 |
|
||||
| `llm-pi-ai` `resolveProfiles` | 施加这条共享规则,保留其「omit it to use ambient authentication」的措辞,并把 trim 后的值写进解析后的 profile。 |
|
||||
| `llm-pi-ai` `resolveApiKey` | 归一化凭据与环境路径。不指定任何凭据的 profile 仍返回 `undefined`,ambient 与 OAuth 路由不受影响。 |
|
||||
| `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 成为凭据故障而非端点不可达。不带 Key 的探测保持未鉴权。 |
|
||||
| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交**与端点探测**同时受拦截,因此被拒绝的密钥不会白花一次往返去换取字段上已经写明的答案;失败呈现在字段上,与既有的 `modelFailure` 模式一致。 |
|
||||
@@ -68,8 +64,6 @@ Status: implemented
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。
|
||||
|
||||
**由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。
|
||||
|
||||
**在 `llm-deepseek` 与 `llm-pi-ai` 中各留一个抛错 helper。** 最初的计划正是各留一份,差别仅在消息中的包名前缀,并配一个重复检测豁免来放行这一对。在实现之前即被否决:`LlmError` 声明在 seam 中,因此 seam 完全可以自己拥有这句诊断,而那里的一个豁免恰恰会掩盖它本要遮掩的重复。
|
||||
@@ -80,7 +74,7 @@ Status: implemented
|
||||
|
||||
**让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。
|
||||
|
||||
**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。
|
||||
**在保存时探测 provider 以证明 Key 可用。** 它能关掉最初报告的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -100,7 +94,7 @@ Status: implemented
|
||||
|
||||
`packages/llm/llm/tests/api-key.spec.ts` 以整张输入表驱动 `normalizeApiKey` 与 `assertUsableApiKey`——空值、纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角、latin-1,以及可打印 ASCII 的边界字符——并钉住一次拒绝携带 `INVALID_CREDENTIAL` 且不含 Key 的任何部分。
|
||||
|
||||
`packages/llm/llm-deepseek/tests/` 在 `adapter.spec.ts` 中覆盖字面量配置路径,在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖 `resolveProfiles`——包括 trim 后的值确实到达解析后的 profile,否则会被 `...rest` 展开丢弃——以及探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。
|
||||
`packages/llm/llm-deepseek/tests/` 在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。
|
||||
|
||||
`packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 同时拦截提交与探测、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md
|
||||
2026-08-06-provider-credential-lifecycle.md: ce45207e7ac7224f44e34945e36ba85db0971f09
|
||||
2026-08-06-provider-credential-lifecycle.zh.md: c476417517b8ed72036344a13720a8ba378775e6
|
||||
2026-08-06-provider-credential-lifecycle.md: c28788921e8f1b233b44e19b29ad4d4acaa25022
|
||||
2026-08-06-provider-credential-lifecycle.zh.md: 2ea3b21fb6ceb4fa38a0cad0daf47c3b6a98a664
|
||||
|
||||
@@ -12,7 +12,7 @@ The Models editor spans independent settings and credential RPC domains. It prev
|
||||
|
||||
Provider save remains a two-stage settings-then-credentials operation over the existing wire domains, but the card treats the successful settings response as a commit checkpoint. It replaces its comparison subtree and expected revision with the returned redacted descriptor before attempting `credentials.set`; if that second stage fails, the draft key and card stay visible, and retry produces no settings ops and repeats only the credential write. Genuine concurrent changes before the first settings commit still fail with `settings-conflict`. Typed keys are trimmed at the UI and direct DeepSeek resolver boundaries, and pi-ai records a derived reference only when the normalized key is non-empty; saving a blank key materializes an empty, reference-free profile for provider-native discovery.
|
||||
|
||||
Deletion removes a credential only when the joined row identifies the exact `<ROUTE>_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. Rows expose API-key state only from the value-free join: a confirmed literal or referenced credential is a green solid dot, a confirmed missing named reference is a red solid dot, and reference-free provider-native authentication or unavailable credential enrichment has no dot. Each dot has accessible copy and a tooltip, while successful Apply uses the same provider identity in a local status message and never echoes secret material.
|
||||
Deletion removes a credential only when the joined row identifies the exact `<ROUTE>_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. Rows expose API-key state only from the value-free join: a confirmed referenced credential is a green solid dot, a confirmed missing named reference is a red solid dot, and reference-free provider-native authentication or unavailable credential enrichment has no dot. Each dot has accessible copy and a tooltip, while successful Apply uses the same provider identity in a local status message and never echoes secret material.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -20,8 +20,8 @@ Deletion removes a credential only when the joined row identifies the exact `<RO
|
||||
|
||||
**Delete every credential reference named by a removed profile.** A custom reference can be shared, externally managed, or intentionally survive profile churn. Exact equality with this page's derived target plus configured+writable state is the narrow evidence available to the page; anything weaker risks deleting a credential it does not own.
|
||||
|
||||
**Remove settings first and compensate by recreating the profile.** The browser holds only a redacted subtree and cannot faithfully reconstruct stored literal secrets or concurrent edits. Credential-first deletion leaves the authoritative profile visible on partial failure and makes retry safe without synthesizing configuration.
|
||||
**Remove settings first and compensate by recreating the profile.** The browser holds only a redacted subtree and cannot faithfully reconstruct concurrent edits. Credential-first deletion leaves the authoritative profile visible on partial failure and makes retry safe without synthesizing configuration.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Models page can recover from either second-stage failure without reload, secret disclosure, or a false concurrency conflict, and blank-key pi-ai profiles preserve Bedrock, Vertex, and other provider-native authentication. Confirmed status is visible without turning route liveness, native authentication, or a failed credential lookup into a false error, and a successful replacement remains observable even when the row stays green. Deleting a page-managed provider no longer leaves a reusable local key, while ambiguous credentials deliberately remain for manual management. Save and delete are still not atomic across durable stores: a process crash can stop between stages, but their order and idempotence leave an observable, retryable state. Component tests pin partial-success retries, empty-key native auth, normalized literals, status visibility, target identity, cleanup ownership, and credential/settings rejection ordering; the keyless browser scenario pins bilingual accessible copy and verifies that confirmed deletion removes both `settings.yaml` profile and `.env` credential. This decision refines the Models apply semantics recorded in the [web configuration plane note](../architecture/2026-07-30-web-config-plane.md).
|
||||
The Models page can recover from either second-stage failure without reload, secret disclosure, or a false concurrency conflict, and blank-key pi-ai profiles preserve Bedrock, Vertex, and other provider-native authentication. Confirmed status is visible without turning route liveness, native authentication, or a failed credential lookup into a false error, and a successful replacement remains observable even when the row stays green. Deleting a page-managed provider no longer leaves a reusable local key, while ambiguous credentials deliberately remain for manual management. Save and delete are still not atomic across durable stores: a process crash can stop between stages, but their order and idempotence leave an observable, retryable state. Component tests pin partial-success retries, empty-key native auth, trimmed key handling, status visibility, target identity, cleanup ownership, and credential/settings rejection ordering; the keyless browser scenario pins bilingual accessible copy and verifies that confirmed deletion removes both the `settings.yaml` profile and `.credentials.yaml` entry. This decision refines the Models apply semantics recorded in the [web configuration plane note](../architecture/2026-07-30-web-config-plane.md).
|
||||
|
||||
@@ -12,7 +12,7 @@ Models 编辑器横跨互相独立的 settings 与凭据 RPC 领域。之前它
|
||||
|
||||
提供方保存仍在现有 wire 领域上按先 settings、后凭据的两阶段顺序执行,但卡片会把成功的 settings 响应视为提交检查点。它会在尝试 `credentials.set` 之前,用返回的脱敏 descriptor 替换比较基准子树与预期 revision;如果第二阶段失败,草稿密钥与卡片会继续显示,重试不会产生 settings op,只会再次写入凭据。首次 settings 提交之前发生的真实并发变更仍会以 `settings-conflict` 失败。UI 与 DeepSeek 直连 resolver 边界均会去除所输密钥的首尾空白,且只有标准化密钥非空时,pi-ai 才会记录派生引用;留空密钥会具化一个空的、不带引用的 profile,以便使用提供方原生凭据发现。
|
||||
|
||||
只有当联接所得的行识别出该页面派生的精确 `<ROUTE>_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。行只根据不含值的联接结果展示 API 密钥状态:确认已配置的字面密钥或引用凭据显示为绿色实心点,确认缺失的具名引用显示为红色实心点,无引用的提供方原生认证或无法取得凭据补充信息时则不显示状态点。每个状态点都有无障碍文案和工具提示;「应用」成功后的本地状态消息会使用同一个提供方标识,且绝不回显任何机密内容。
|
||||
只有当联接所得的行识别出该页面派生的精确 `<ROUTE>_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。行只根据不含值的联接结果展示 API 密钥状态:确认已配置的引用凭据显示为绿色实心点,确认缺失的具名引用显示为红色实心点,无引用的提供方原生认证或无法取得凭据补充信息时则不显示状态点。每个状态点都有无障碍文案和工具提示;「应用」成功后的本地状态消息会使用同一个提供方标识,且绝不回显任何机密内容。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -20,8 +20,8 @@ Models 编辑器横跨互相独立的 settings 与凭据 RPC 领域。之前它
|
||||
|
||||
**删除被移除 profile 所指定的每一个凭据引用。**自定义引用可能被共享、由外部管理,或有意在 profile 反复增删时存留。与该页面派生目标精确相等,再加上已配置且可写的状态,是页面所能获得的最小范围证据;比这更弱的判定都有可能删除不属于它的凭据。
|
||||
|
||||
**先删除 settings,再重建 profile 以作补偿。**浏览器只持有脱敏后的子树,无法忠实重建已存的字面机密或并发编辑。先删除凭据可以让权威 profile 在部分失败时仍然可见,并且无需合成配置就能安全重试。
|
||||
**先删除 settings,再重建 profile 以作补偿。**浏览器只持有脱敏后的子树,无法忠实重建并发编辑。先删除凭据可以让权威 profile 在部分失败时仍然可见,并且无需合成配置就能安全重试。
|
||||
|
||||
## 后果
|
||||
|
||||
Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。已确认的状态清晰可见,同时不会把路由存活状态、原生认证或凭据查询失败误报为错误;即使该行继续显示绿色,密钥替换成功也仍然可观察。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、标准化字面值、状态可见性、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.env` 凭据。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。
|
||||
Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。已确认的状态清晰可见,同时不会把路由存活状态、原生认证或凭据查询失败误报为错误;即使该行继续显示绿色,密钥替换成功也仍然可观察。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、密钥首尾空白处理、状态可见性、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.credentials.yaml` 条目。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。
|
||||
|
||||
Reference in New Issue
Block a user