Compare commits
318
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46a7f68b09 | ||
|
|
a60af51e80 | ||
|
|
07ad70817f | ||
|
|
c76217a356 | ||
|
|
490f569044 | ||
|
|
7c8a44ac31 | ||
|
|
b09dd94827 | ||
|
|
f64c57dbb2 | ||
|
|
51d70c5f5c | ||
|
|
96a4607ba2 | ||
|
|
6943f9fee6 | ||
|
|
5a32aae571 | ||
|
|
1b55f5bea3 | ||
|
|
4987e90b32 | ||
|
|
ac77cefba7 | ||
|
|
d6fb1351b3 | ||
|
|
015a9b202c | ||
|
|
b660f72d4e | ||
|
|
fb8bb16345 | ||
|
|
312341970c | ||
|
|
9b510d89a5 | ||
|
|
896c81d796 | ||
|
|
747c98b0db | ||
|
|
5ea77078d6 | ||
|
|
2c67633990 | ||
|
|
19d6df2b2f | ||
|
|
e0e8327992 | ||
|
|
7eb9629e0a | ||
|
|
4aa207c5fe | ||
|
|
e4234651ee | ||
|
|
c5dc0853d6 | ||
|
|
bd2a49a2ca | ||
|
|
45f5cd34b0 | ||
|
|
62aef4c583 | ||
|
|
4839239954 | ||
|
|
e68fb0bc8d | ||
|
|
4cfd292a7b | ||
|
|
dc0ae81905 | ||
|
|
a8776afa87 | ||
|
|
219db5454a | ||
|
|
3675e91cf9 | ||
|
|
5e25475857 | ||
|
|
f4c6b012aa | ||
|
|
fe4d18fda3 | ||
|
|
907a241b61 | ||
|
|
e5e9b5b982 | ||
|
|
7fe9c06fa6 | ||
|
|
c920174964 | ||
|
|
3776169373 | ||
|
|
19675fd061 | ||
|
|
536764c8b4 | ||
|
|
b66b602c72 | ||
|
|
913aa2289b | ||
|
|
701026b5bf | ||
|
|
5840898e07 | ||
|
|
b2817d77fb | ||
|
|
a60d4df027 | ||
|
|
833835d95c | ||
|
|
74511b3dcb | ||
|
|
d3086e6571 | ||
|
|
d550f1c4af | ||
|
|
ccaa0dc11c | ||
|
|
19844ea9ad | ||
|
|
a2278b460c | ||
|
|
80e4da0e3d | ||
|
|
f132684c81 | ||
|
|
3d6d46b8a6 | ||
|
|
dd004b4221 | ||
|
|
38596d5c70 | ||
|
|
8833ef9f63 | ||
|
|
995caa05ed | ||
|
|
10292baeff | ||
|
|
bbeef5f250 | ||
|
|
d52424042c | ||
|
|
2ea753960c | ||
|
|
2b44a4b657 | ||
|
|
4face54ea7 | ||
|
|
0b62921f37 | ||
|
|
86e3e0e448 | ||
|
|
61936aa113 | ||
|
|
3b8be2928f | ||
|
|
3c20f401d2 | ||
|
|
a56e82e5dd | ||
|
|
a96d45af40 | ||
|
|
63cc821e7e | ||
|
|
df7fa8945c | ||
|
|
edd0a438bf | ||
|
|
02383832df | ||
|
|
5082e05f71 | ||
|
|
c4955b1900 | ||
|
|
354da32a38 | ||
|
|
f546068553 | ||
|
|
3bf6d738f1 | ||
|
|
5124a2a310 | ||
|
|
2aa3a469db | ||
|
|
3f07e34b5e | ||
|
|
d7b45fb415 | ||
|
|
75d74a055e | ||
|
|
f68d4b5aba | ||
|
|
752953d2b6 | ||
|
|
2b9923fede | ||
|
|
c3afd488aa | ||
|
|
3f0022a8fa | ||
|
|
040f47a822 | ||
|
|
c68c6db9ca | ||
|
|
0067efc8ae | ||
|
|
d40dbef91b | ||
|
|
5bc57902d5 | ||
|
|
dc374b8879 | ||
|
|
14a062acb7 | ||
|
|
ceb997cdef | ||
|
|
60b5800816 | ||
|
|
0edfe83573 | ||
|
|
00102833df | ||
|
|
10ea83bcc3 | ||
|
|
178ee3a9eb | ||
|
|
4bf9d2e0c0 | ||
|
|
d0dca04e22 | ||
|
|
3950647caa | ||
|
|
3889e97de0 | ||
|
|
08c0e8e71b | ||
|
|
43a8a22824 | ||
|
|
9095678c8c | ||
|
|
9fcb76d9bc | ||
|
|
f6966f8fab | ||
|
|
fad9e27706 | ||
|
|
838eff14eb | ||
|
|
4e6028a604 | ||
|
|
b9b5f7c58e | ||
|
|
7021420f29 | ||
|
|
37372101b5 | ||
|
|
c92d5ebe18 | ||
|
|
820824edd7 | ||
|
|
50ba2c8bb2 | ||
|
|
2251b6189b | ||
|
|
13dfae20c3 | ||
|
|
fb02779a26 | ||
|
|
1f029fc442 | ||
|
|
633cf7394e | ||
|
|
6b4055bf32 | ||
|
|
e8d5278c46 | ||
|
|
3dcfbf2094 | ||
|
|
6a07033145 | ||
|
|
5925925d20 | ||
|
|
a6b909c767 | ||
|
|
b73031be9b | ||
|
|
fd784e4429 | ||
|
|
969c3df8c1 | ||
|
|
1498b01923 | ||
|
|
bf3de68213 | ||
|
|
37e4dc2dee | ||
|
|
a44ced5b96 | ||
|
|
d4b109f439 | ||
|
|
bef0869d14 | ||
|
|
82a7dd9c3f | ||
|
|
84394b818c | ||
|
|
5b600047d2 | ||
|
|
ec852793b5 | ||
|
|
bdbf1fe339 | ||
|
|
3c50bf6b6c | ||
|
|
20e7407ff1 | ||
|
|
d6e5b7e41a | ||
|
|
c22b226b18 | ||
|
|
af28c551f3 | ||
|
|
b34b5d33a1 | ||
|
|
603a8fc7d3 | ||
|
|
505559b03b | ||
|
|
30a7f8f787 | ||
|
|
7c296b3f08 | ||
|
|
fac0d8d877 | ||
|
|
b2ec54159f | ||
|
|
108f767a89 | ||
|
|
7b2094d04d | ||
|
|
03294cb653 | ||
|
|
d8924486ad | ||
|
|
a79e3a2a5f | ||
|
|
c81adc2569 | ||
|
|
a294fc55d0 | ||
|
|
aba3472b1d | ||
|
|
d257fa4af7 | ||
|
|
b980fdf5df | ||
|
|
e420c44d60 | ||
|
|
bada62b50b | ||
|
|
6ec97fa124 | ||
|
|
9c6c407da2 | ||
|
|
7c84d9d790 | ||
|
|
3ae2f687f1 | ||
|
|
d3c5f959aa | ||
|
|
58651b8359 | ||
|
|
2d653be7b0 | ||
|
|
5929af046c | ||
|
|
780e07b8ed | ||
|
|
32ca127565 | ||
|
|
536b444f36 | ||
|
|
f5d7de2a01 | ||
|
|
4777947b6c | ||
|
|
930d187e04 | ||
|
|
ee0048558d | ||
|
|
34a0a8e81d | ||
|
|
700b636508 | ||
|
|
e76a5eba21 | ||
|
|
785a92ab03 | ||
|
|
3b6a5399e8 | ||
|
|
fd8bed1ad0 | ||
|
|
62f816f3a5 | ||
|
|
bfc772bb47 | ||
|
|
749f05e9fa | ||
|
|
ab7cdbee75 | ||
|
|
41fd0baf91 | ||
|
|
f50059e425 | ||
|
|
5ca6d34ac6 | ||
|
|
8cf07ea76c | ||
|
|
b6775f6d4f | ||
|
|
2c098434b9 | ||
|
|
13fe312e15 | ||
|
|
753df93b85 | ||
|
|
8ae7631861 | ||
|
|
b1c084e769 | ||
|
|
13d9606675 | ||
|
|
c74e36acfc | ||
|
|
e950c8bea4 | ||
|
|
a19a48edea | ||
|
|
cafb9b93b4 | ||
|
|
5f1a524f61 | ||
|
|
db4bfb4c96 | ||
|
|
6a133a02b8 | ||
|
|
43bd7010d0 | ||
|
|
1a21e958df | ||
|
|
7befda1147 | ||
|
|
bed4a2b565 | ||
|
|
dbd34bcc0d | ||
|
|
c9591d9445 | ||
|
|
d459183f32 | ||
|
|
b7d9c0f6a4 | ||
|
|
843e1b97af | ||
|
|
ce2ba9b216 | ||
|
|
ee4458f6d0 | ||
|
|
d407a6ce90 | ||
|
|
4544874d62 | ||
|
|
a4c74a91e0 | ||
|
|
6530130ffa | ||
|
|
e38cca76fd | ||
|
|
8e7cca22ca | ||
|
|
943af81a18 | ||
|
|
5864a6f6f1 | ||
|
|
afde35880f | ||
|
|
9f52c6401e | ||
|
|
22be805026 | ||
|
|
9f9a50e553 | ||
|
|
cfa84ed4e3 | ||
|
|
ab62f09c22 | ||
|
|
dccf989cd8 | ||
|
|
c5ca36387b | ||
|
|
95611fbc63 | ||
|
|
f5c96b6340 | ||
|
|
670a903237 | ||
|
|
b74bef5d40 | ||
|
|
817623750a | ||
|
|
8aff71863b | ||
|
|
9d91bb01c9 | ||
|
|
3305bb1938 | ||
|
|
5ac920e50c | ||
|
|
adbb0e0dc4 | ||
|
|
2426d398b5 | ||
|
|
de45804084 | ||
|
|
091db6c8c3 | ||
|
|
94728ebd23 | ||
|
|
06923b8868 | ||
|
|
6b227a81a7 | ||
|
|
4137d1d628 | ||
|
|
d08aac9db2 | ||
|
|
5ca4fea9b5 | ||
|
|
44900be005 | ||
|
|
68e43b173e | ||
|
|
f114233545 | ||
|
|
8d2cd0cf72 | ||
|
|
53abf11bdd | ||
|
|
f384ee7418 | ||
|
|
89251dde83 | ||
|
|
7ebbd370cd | ||
|
|
719cc56276 | ||
|
|
894f25b91e | ||
|
|
bc4e8bafe6 | ||
|
|
5aa21749b9 | ||
|
|
c1edda8a37 | ||
|
|
1566f3f5ad | ||
|
|
481b6b57e5 | ||
|
|
754733d5d1 | ||
|
|
f3c6d0c0df | ||
|
|
68b44d78ba | ||
|
|
575dc9add0 | ||
|
|
b0be6e79c2 | ||
|
|
14c1fec958 | ||
|
|
79468e26dd | ||
|
|
4694540e2a | ||
|
|
e52209da40 | ||
|
|
5c1d966c3f | ||
|
|
74f4c438de | ||
|
|
552235aab6 | ||
|
|
c4c18ef0d4 | ||
|
|
38dbb56237 | ||
|
|
ab102138c8 | ||
|
|
1dc518f217 | ||
|
|
b900840784 | ||
|
|
4f74f956ec | ||
|
|
4e47ba6c1d | ||
|
|
97545d9c8f | ||
|
|
4df3de2075 | ||
|
|
a2487e34b7 | ||
|
|
cbd6562506 | ||
|
|
b79b64f788 | ||
|
|
c2be3d9a0b | ||
|
|
5015426866 | ||
|
|
422ded6f7a | ||
|
|
06ef26a80f | ||
|
|
6613660223 | ||
|
|
40edba6a8d | ||
|
|
3515c48a37 |
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md
|
||||
2026-07-08-tool-output-spill-files.md: fa6697aad63c97b0ef580fe1ca46e50541e3d682
|
||||
2026-07-08-tool-output-spill-files.zh.md: a995eafc7878b342a2164c5116da1f43ada791a5
|
||||
2026-07-08-tool-output-spill-files.md: e32aec72f423dff33a29d9012c0f71c9cfbd24fb
|
||||
2026-07-08-tool-output-spill-files.zh.md: 2941fc516723e72b5d5670e674ce612f3cf581cc
|
||||
@@ -66,36 +66,7 @@ interface SpillRef {
|
||||
|
||||
### Spill policy
|
||||
|
||||
`dsh-spill-policy` is a `tools/post-execute` result transformer with one configuration knob:
|
||||
|
||||
```ts ignore-check
|
||||
interface Config {
|
||||
/** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */
|
||||
maxInlineBytes?: number
|
||||
}
|
||||
```
|
||||
|
||||
When `maxInlineBytes` is omitted the plugin registers nothing (a true no-op). When set, it applies a default policy to final plain-text tool results:
|
||||
|
||||
1. Let the tool run normally, delegating via `next()` so a downstream listener settles the result first.
|
||||
2. Flatten the accepted final `ContentBlock[]` only when it is entirely plain text; a result with any non-text block is left untouched.
|
||||
3. If its UTF-8 byte size is at or below `maxInlineBytes`, leave it unchanged.
|
||||
4. If it is larger, call `ctx.spillStore.saveText()` with the full final text.
|
||||
5. Replace the model-facing result with a retained head/tail preview plus the spill reference.
|
||||
|
||||
The preview is an implementation default owned by the policy: a head/tail split of `maxInlineBytes` via the retention library's `TextRetainer`. Future config can expose preview sizing only after a second deployment needs it.
|
||||
|
||||
The replacement text is intentionally generic because the policy only knows the final formatted tool result, not the tool's internal resource:
|
||||
|
||||
```text
|
||||
<retained preview>
|
||||
|
||||
(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
|
||||
```
|
||||
|
||||
If `ctx.spillStore.saveText()` fails (permissions, ENOSPC, backend unavailable), or the call has no session owner, or no backend is loaded, the plugin logs the reason and returns the original result unchanged. Spill failure never turns a successful tool call into an `isError` result or hides the inline result.
|
||||
|
||||
The policy skips `read` to avoid a circular `read -> spill file -> read again` loop. Additional opt-out configuration is deferred until a real second tool needs it.
|
||||
`dsh-spill-policy` retains ordered text/image ends under `maxInlineTokens` after post-execute policy accepts the result. `saveText()` stores the complete result with readable image paths; image bytes remain in attachment storage. The [multimodal retention decision](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.md) owns image projection order, atomic image omission, and route pricing. An omitted budget installs no listeners, recovery failures keep the original result, and model-facing `read` results skip retention to avoid a read/spill loop.
|
||||
|
||||
## Showcase: web_fetch
|
||||
|
||||
@@ -129,7 +100,7 @@ With `dsh-spill-policy` configured, a large formatted fetch result is automatica
|
||||
- id: spill-policy
|
||||
name: '@deepseek-ai/dsh-spill-policy'
|
||||
config:
|
||||
maxInlineBytes: 50000
|
||||
maxInlineTokens: 12500
|
||||
```
|
||||
|
||||
This separation is important. `web-fetch-http` still owns resource caps (`maxResponseBytes`, `maxBodyChars`) to protect network, memory, and decoding work. `spill-policy` owns only the model-facing context cap after the result already exists. If the provider already returned `truncated: true`, the spill file contains the full formatted result the tool returned, not the full original webpage; the policy does not claim otherwise.
|
||||
@@ -186,11 +157,11 @@ The local-backend value proposition depends on the existing `read`/`grep` tools
|
||||
|
||||
**Snapshot gap.** No ACP snapshot scenario covers the transcript-visible `web_fetch` spill notice yet. The ACP snapshot harness replays keyless and cannot hit the live web, and a `web_fetch` spill requires a real over-cap HTTP body; a deterministic scenario would need a seeded loopback fetch target the replay tree does not currently wire (the examples do not load `tool-web` at all). The behavior is covered instead by the `dsh-tool-web` integration test against a loopback server. Closing the gap is follow-up work: wire `tool-web` + a seeded fetch target into the ACP example, then record a `web-fetch-spill` scenario.
|
||||
|
||||
The policy can become too large if it starts owning tool-specific semantics. It stays narrow: plain-text final results only. Tool-owned early spill remains future work.
|
||||
The policy can become too large if it starts owning tool-specific semantics. It handles accepted text/image sequences without interpreting tool-specific text. Tool-owned early spill remains future work.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Require each tool to opt in with a retention declaration.** Rejected: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineBytes` deployment knob is enough to prove the shape.
|
||||
**Require each tool to opt in with a retention declaration.** Rejected: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineTokens` deployment knob is enough to prove the shape.
|
||||
|
||||
**Make `tool-results` a broad tool-result platform.** Rejected: a broad package name invites retention policy, result replacement, preview wording, search, and early spill into one seam. The shared storage part is smaller: save text and return a locator plus retrieval hint.
|
||||
|
||||
|
||||
@@ -66,36 +66,7 @@ interface SpillRef {
|
||||
|
||||
### spill 策略
|
||||
|
||||
`dsh-spill-policy` 是一个 `tools/post-execute` 结果转换器,只提供一个配置项:
|
||||
|
||||
```ts ignore-check
|
||||
interface Config {
|
||||
/** Omitted means no automatic spill policy. Present means apply to oversized plain text tool results. */
|
||||
maxInlineBytes?: number
|
||||
}
|
||||
```
|
||||
|
||||
省略 `maxInlineBytes` 时,插件不会注册任何内容,是真正的无操作。设置该值后,它会对最终的纯文本工具结果应用默认策略:
|
||||
|
||||
1. 让工具正常运行,通过 `next()` 委托,使下游监听器先结算结果。
|
||||
2. 仅当已接受的最终 `ContentBlock[]` 全部是纯文本时,才将其展平;含任何非文本块的结果保持不变。
|
||||
3. 如果 UTF-8 字节大小不超过 `maxInlineBytes`,保持不变。
|
||||
4. 如果超出上限,使用完整的最终文本调用 `ctx.spillStore.saveText()`。
|
||||
5. 把模型可见结果替换为保留的首尾预览和 spill 引用。
|
||||
|
||||
预览属于策略所有的实现默认值:以 `maxInlineBytes` 为上限,使用保留库的 `TextRetainer` 进行首尾分割。只有第二个部署证明有此需求后,未来配置才会公开预览大小。
|
||||
|
||||
替换文本刻意保持通用,因为策略只知道最终格式化的工具结果,不了解工具的内部资源:
|
||||
|
||||
```text
|
||||
<retained preview>
|
||||
|
||||
(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)
|
||||
```
|
||||
|
||||
如果 `ctx.spillStore.saveText()` 失败(权限、ENOSPC、后端不可用),或调用没有会话所有者,或未加载后端,插件会记录原因并原样返回结果。spill 失败绝不会把成功的工具调用变为 `isError` 结果,也不会隐藏内联结果。
|
||||
|
||||
策略跳过 `read`,以避免形成 `read -> spill file -> read again` 循环。额外的选择退出配置要等确实出现第二个有此需求的工具后再引入。
|
||||
`dsh-spill-policy` 在执行后策略接受结果之后,按 `maxInlineTokens` 对文字和图片共同计量并保留首尾。完整结果仍通过 `saveText()` 保存,图片本体留在附件存储,结果文件记录可读取路径。图片投影顺序、整图省略和模型计量由[图文结果保留决策](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.zh.md)负责。省略配置时不安装监听器,存储失败时保留原结果;`read` 的模型可见结果跳过自动省略,避免重复读取产生循环。
|
||||
|
||||
## 示例:web_fetch
|
||||
|
||||
@@ -129,7 +100,7 @@ ctx.tools.register(defineTool({
|
||||
- id: spill-policy
|
||||
name: '@deepseek-ai/dsh-spill-policy'
|
||||
config:
|
||||
maxInlineBytes: 50000
|
||||
maxInlineTokens: 12500
|
||||
```
|
||||
|
||||
这项分离很重要。`web-fetch-http` 仍负责资源上限(`maxResponseBytes`、`maxBodyChars`),用来保护网络、内存和解码工作。`spill-policy` 只负责结果已经存在后针对模型上下文的上限。如果提供方已经返回 `truncated: true`,spill 文件包含的是工具返回的完整格式化结果,而不是原始网页全文;策略不会做出其他承诺。
|
||||
@@ -186,11 +157,11 @@ ctx.tools.register(defineTool({
|
||||
|
||||
**快照缺口。** 目前没有 ACP 快照场景覆盖 transcript(文本记录)可见的 `web_fetch` spill 提示。ACP 快照 harness 在无密钥环境中回放,无法访问实时 web,而 `web_fetch` spill 需要一个真实的超上限 HTTP 正文;确定性场景需要一个预置的 loopback fetch 目标,但当前回放树尚未接线(示例根本没有加载 `tool-web`)。该行为改由 `dsh-tool-web` 针对 loopback server 的集成测试覆盖。弥补该缺口属于后续工作:把 `tool-web` 和预置 fetch 目标接入 ACP 示例,然后录制 `web-fetch-spill` 场景。
|
||||
|
||||
如果策略开始负责工具专用语义,就会膨胀得过大。它的范围保持狭窄:只处理纯文本最终结果。由工具负责的提前 spill 仍留作未来工作。
|
||||
如果策略开始负责工具专用语义,就会膨胀得过大。它处理已接受的图文序列,不解释工具专用的文字语义。由工具负责的提前 spill 仍留作未来工作。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**要求每个工具通过保留声明选择加入。**不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineBytes` 部署配置项即可验证该形态。
|
||||
**要求每个工具通过保留声明选择加入。**不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineTokens` 部署配置项即可验证该形态。
|
||||
|
||||
**把 `tool-results` 建成宽泛的工具结果平台。** 不予采纳:宽泛的包名会诱使系统把保留策略、结果替换、预览措辞、搜索和提前 spill 合并进一个 seam。可共享的存储部分更小:保存文本,并返回定位符与检索提示。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
|
||||
2026-08-09-client-conversation-node-assembly.md: bb1d68cc045710d46a44ffb295c2307628338521
|
||||
2026-08-09-client-conversation-node-assembly.zh.md: 6291211d589b3389e7dc9a42d15fe6ac0d9e7ab9
|
||||
2026-08-09-client-conversation-node-assembly.md: c8773b77fe761c3a566e02f0bd6aeb18f26e0a89
|
||||
2026-08-09-client-conversation-node-assembly.zh.md: 076ad35178996e01eae26b3909feae8d967414c1
|
||||
@@ -14,6 +14,8 @@ Business events also use different correlation models. Tool has call IDs, Assist
|
||||
|
||||
## Decision
|
||||
|
||||
The [three-stage Tool lifecycle](2026-09-22-tool-call-three-phases.md) owns transient initialization, repeated start matches for one ID, and tool preparation; this record's other node-assembly responsibilities remain applicable.
|
||||
|
||||
Client Runtime provides a target-neutral Conversation Node assembly engine. Business plugins register Event Definitions, and view plugins register per-Session View Builders. `ui-conversation` registers the first built-in Definitions and the `chat` builder; Session only submits the current contiguous `SessionEventLikeEntry` window to the engine and publishes its snapshot instead of interpreting individual conversation businesses. The entry's outer discriminator distinguishes standard and packed records, while both carry an aligned inner `SessionEventLike` for Definition dispatch.
|
||||
|
||||
This Note retains the derivation, business-by-business validation, responsibilities, algorithms, and trade-offs that remain relevant after implementation.
|
||||
|
||||
+2
@@ -14,6 +14,8 @@ Client Session 既维护传输窗口、连接状态和待处理交互,也在
|
||||
|
||||
## 决策
|
||||
|
||||
start 的瞬态创建入口、同 ID 多次 start 及工具准备阶段以[工具调用三阶段](2026-09-22-tool-call-three-phases.zh.md)为准;本记录的其他节点组装职责继续适用。
|
||||
|
||||
Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务插件注册 Event Definition,视图插件注册 per-Session View Builder。`ui-conversation` 注册第一批内建 Definition 和 `chat` builder;Session 只负责把当前连续 `SessionEventLikeEntry` window 送入引擎并发布它的 snapshot,且不解释具体 conversation 业务。entry 的外层 discriminator 区分标准与 packed record,两者都携带字段对齐的内部 `SessionEventLike`,供 Definition dispatch。
|
||||
|
||||
本 Note 保留实现后仍有价值的方案推导、逐业务适配、职责、算法和取舍。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md
|
||||
2026-09-14-deepseek-account-login.md: 9d9853617a3b7e288084c93eac4e150f3c180999
|
||||
2026-09-14-deepseek-account-login.zh.md: d9cdbed25b0ae79caa4c61b310383cc52d8b586a
|
||||
2026-09-14-deepseek-account-login.md: 458e020c2000fd3db5ee9806e99d4b653710cb1e
|
||||
2026-09-14-deepseek-account-login.zh.md: 2fb59009302309a67e2c3ec99050685ba39b4212
|
||||
@@ -36,7 +36,7 @@ The account plugin supplies choice, waiting, failure and timeout dialogs through
|
||||
|
||||
Platform embedding transfers a grant from Host to Electron over private Node IPC and then once to the trusted Platform main frame through a sandboxed preload. Preload performs one synchronous IPC before page scripts execute; the main process only validates the sender and returns prepared memory. Platform reads the token synchronously thereafter without a readiness API. A failed initialization retains embedded mode and a throwing getter. Account UI projections remain credential-free. The Platform document can read this credential, so its script security is part of account protection; context isolation protects native capabilities, not a token intentionally returned to the document. Replacing or removing the grant destroys the document and its temporary session.
|
||||
|
||||
Support questionnaires receive only the Platform UID and available environment metadata through explicit prefill fields. Account tokens and masked contact details stay out of questionnaire URLs. The account provider projects the current-user UID for this purpose; the UI uses the latest account snapshot so sign-out cannot reuse a previous UID.
|
||||
Support questionnaires receive only available environment metadata through explicit prefill fields. Account UIDs, tokens and masked contact details stay out of questionnaire URLs.
|
||||
|
||||
Private proxy development can explicitly map authorization and completion URLs to platformOrigin with rewriteBrowserOrigin. Both pages retain their fixed paths and full query strings; allowing a remote origin without mapping it would send the browser outside the configured environment. Shipped configuration requires same-origin URLs.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ API Key 与账号记录独立保存。账号 token 没有过期或刷新流程
|
||||
|
||||
Platform 内嵌通过私有 Node IPC 将授权从 Host 传给 Electron,再经沙箱 preload 一次性传给受信任的 Platform 主 frame。preload 在页面脚本执行前执行一次同步 IPC;主进程仅校验调用来源并返回已准备的内存数据。Platform 此后同步读取 token,无需就绪接口。初始化失败时保留内嵌模式,由 getter 抛错。账号 UI 投影仍不包含凭证。Platform 文档能够读取此凭证,因此其脚本安全也是账号保护的一部分;上下文隔离保护原生能力,不能隐藏有意返回给文档的 token。替换或移除授权会销毁文档及其临时会话。
|
||||
|
||||
支持问卷仅通过显式预填字段接收 Platform UID 和已有的环境信息,URL 不包含账号 token 或脱敏联系方式。账号提供者为此投影 current-user 的 UID;UI 使用最新账号快照,避免退出登录后复用旧 UID。
|
||||
支持问卷仅通过显式预填字段接收已有的环境信息,URL 不包含账号 UID、token 或脱敏联系方式。
|
||||
|
||||
私有代理开发可通过 rewriteBrowserOrigin 显式将授权页和完成页映射到 platformOrigin。两者保留固定路径和完整查询字符串;仅放宽来源而不做映射会让浏览器离开配置的环境。发布配置要求浏览器地址同源。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md
|
||||
2026-09-15-desktop-native-fatal-recovery.md: 94ead425f5483ba3e6ed5d9d595edb4483254c27
|
||||
2026-09-15-desktop-native-fatal-recovery.zh.md: 2138fb11405d00d51775350bf60d2702621aaa19
|
||||
2026-09-15-desktop-native-fatal-recovery.md: fc1564b02e2f7b707d2b6f6d8e0947b1a78f142d
|
||||
2026-09-15-desktop-native-fatal-recovery.zh.md: ceda54b4141c5fe58e6b385c86e6c1e4958dd261
|
||||
@@ -12,7 +12,7 @@ A recovery document depends on the renderer and preload whose failure can preven
|
||||
|
||||
Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests retain local error handling, and the shared Web plugin manager owns package-operation failures. Expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal.
|
||||
|
||||
The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
|
||||
The first report claims presentation before awaiting the dialog. Later reports remain in logs. A crash report file under the application logs directory retains the complete reported diagnostic, and the dialog names it on its own line ([fatal diagnostics and crash reports](2026-09-22-fatal-diagnostics-and-crash-reports.md)). The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice, report path, and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Recovery calls the shared app-boot `sanitizeProfile` function under the existing profile lock after Host shutdown. The function restores caller-supplied bundles and renames the profile patch to a unique backup without parsing it, requiring runtime initialization, or deleting installed files. Callers own profile shutdown and write exclusion; Desktop is the current production caller. The home-level patch remains unchanged. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
|
||||
|
||||
The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. Backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry.
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求保留局部错误处理,共享 Web 插件管理器负责包操作失败。预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
|
||||
|
||||
首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
|
||||
首次报告在等待对话框前取得展示权。后续报告保留在日志中。应用日志目录下的崩溃报告文件保留完整的已报告诊断,对话框单独一行标明该文件([致命诊断与崩溃报告](2026-09-22-fatal-diagnostics-and-crash-reports.zh.md))。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示、报告路径和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。恢复操作等待 Host 关闭后,在已有 profile 锁内调用共享 app-boot `sanitizeProfile` 函数。该函数恢复调用方指定的 bundle,并将 profile patch 重命名为唯一备份,无需解析 patch、初始化运行时或删除安装文件。调用方负责 profile 关闭并排除并发写入;当前生产调用方是 Desktop。home 级 patch 保持不变。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
|
||||
|
||||
Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md
|
||||
2026-09-15-guided-plugin-installation.md: 26ad870856ce948e1ebb736a07004e41af2a17be
|
||||
2026-09-15-guided-plugin-installation.zh.md: 36e76c8ace0ae990ede14436b65fa00752f86bbe
|
||||
2026-09-15-guided-plugin-installation.md: 68bd519b7cb4342c42761dfaef53940c83ceb2d8
|
||||
2026-09-15-guided-plugin-installation.zh.md: f3cbefedeb151d37078b55140635b69516fafed2
|
||||
@@ -14,7 +14,9 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
|
||||
|
||||
**A failed or cancelled installation restores the profile files.** `installBundle` snapshots `package.json` and `pnpm-lock.yaml` before pnpm runs and puts them back when pnpm fails, when the run is cancelled, or when the package pnpm added declares no bundle patch. This reverses, for installations only, the [manager's decision](2026-09-14-current-profile-plugin-management.md) to retain partial changes: an installation that did not produce a usable bundle must not leave a dependency the page cannot show. Removals keep that decision. Downloaded files can stay under `node_modules` and the pnpm store.
|
||||
|
||||
**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test.
|
||||
**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. A run the manager terminated is classified `timeout` whatever exit status the signal left behind, so installation restores the files and removal reports failure instead of reading it as a success ([run bounds](../bug-fix/2026-09-23-bounded-pnpm-runs.md)). The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test.
|
||||
|
||||
**GitHub connection failures have a short budget before installation.** The Host runs a read-only `git ls-remote` check with the profile's Git and proxy configuration and a default `githubConnectionTimeoutMs` of 5000 ms. Credential helpers and prompts are disabled for this check, so authentication cannot consume the connection budget. Only network failures and timeouts prevent pnpm from starting; pnpm still owns authentication, HTTPS-to-SSH fallback, ref resolution and the full installation. The budget does not limit downloads or builds. The existing failure kind and `failedAt: 'spec-host'` let the Client show a connection error or timeout and offer a domestic registry for a replacement package spec. Cancellation and manager disposal terminate the check and its descendants before settling.
|
||||
|
||||
**Cancellation is the manager's, not the signal's.** The dialog generates a request id for each run; the Host streams the run's output and phases under it, and `cancelInstall` answers only after pnpm exited and the files are back. The dialog waits for that answer before offering the spec again; an aborted RPC or a lost connection is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for.
|
||||
|
||||
@@ -36,8 +38,10 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
|
||||
|
||||
## Consequences
|
||||
|
||||
`inspect`, `cancelInstall`, and the `plugin-manager/changed`, `plugin-manager/install-log`, and `plugin-manager/install-state` events join the manager's Remote; `listBundles` carries titles, rows, and overrides; `ChangeResult` gains `cancelled`, `bundle`, and `packageResult.kind`; the config gains `pnpmCommand` and `inspectTimeoutMs`. The dialog is four screens over one subject card.
|
||||
`inspect`, `cancelInstall`, and the `plugin-manager/changed`, `plugin-manager/install-log`, and `plugin-manager/install-state` events join the manager's Remote; `listBundles` carries titles, rows, and overrides; `ChangeResult` gains `cancelled`, `bundle`, and `packageResult.kind`; the config gains `pnpmCommand`, `inspectTimeoutMs` and `githubConnectionTimeoutMs`. The dialog is four screens over one subject card.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/boot/plugin-manager/tests/install-spec.spec.ts` pins the spec forms and the failure classifier's inputs; `manager.spec.ts` drives `inspect` against a stubbed registry lookup and a real directory, streams a run, stops one and checks the restored files, and checks the change events; `operations.spec.ts` covers the registry lookup. `packages/client/ui-plugin-manager/tests` cover the store's phases, Host-confirmed cancellation, post-install enabling, toasts, and the page's four screens; `apps/web/tests/plugin-manager.e2e.ts` refuses an installed name, a missing path, and a bad name through the real Host and switches a bundle and one of its rows live, and `plugin-install-cancel.e2e.ts` stops a real child from the dialog, checks the restored files, and installs on the second try, and `plugin-install-approve.e2e.ts` leaves a script undecided through the fake pnpm, allows it from the dialog, and installs on the retry.
|
||||
|
||||
`github-connection.spec.ts` uses real Git against owned loopback transports to cover immediate failure, timeout, cancellation and noninteractive authentication. `manager.spec.ts` also installs a local repository through real Git and pnpm for HTTPS, GitHub shorthand and SSH specs, isolating pnpm's HTTPS probe through an owned proxy and verifying its SSH fallback. `plugin-install-github.e2e.ts` checks the connection error and timeout dialogs, child cleanup, and switching to a mirror without retrying the old GitHub address.
|
||||
@@ -14,7 +14,9 @@ Status: implemented
|
||||
|
||||
**失败或被取消的安装恢复 profile 文件。** `installBundle` 在 pnpm 运行前快照 `package.json` 与 `pnpm-lock.yaml`,在 pnpm 失败、运行被取消、或 pnpm 装入的包没有声明组合包 patch 时把它们放回去。这只针对安装反转了[管理器的决定](2026-09-14-current-profile-plugin-management.zh.md)中"保留部分改动"的部分:没有产出可用组合包的安装,不能留下一个页面无法显示的依赖。删除仍沿用那个决定。已下载文件可能留在 `node_modules` 与 pnpm 缓存中。
|
||||
|
||||
**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。
|
||||
**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。被管理器终止的运行,不论信号留下什么退出状态都归类为 `timeout`,因此安装会还原文件,删除则报告失败而不会读成成功([运行上界](../bug-fix/2026-09-23-bounded-pnpm-runs.zh.md))。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。
|
||||
|
||||
**GitHub 连接失败在安装前有单独的短时限。** 宿主通过只读的 `git ls-remote` 检查连接,沿用 profile 的 Git 与代理配置,`githubConnectionTimeoutMs` 默认为 5000 毫秒。这次检查禁用凭据助手和认证提示,避免认证过程占用连接预算。只有网络失败与超时会阻止 pnpm 启动;认证、HTTPS 到 SSH 的回退、ref 解析及完整安装仍由 pnpm 负责。时限不约束下载或构建。已有的失败类型与 `failedAt: 'spec-host'` 让客户端显示连接失败或超时,并提供国内镜像供用户输入另一个包名。取消安装或销毁管理器会先终止检查及其子进程,再结束操作。
|
||||
|
||||
**取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。
|
||||
|
||||
@@ -36,8 +38,10 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
`inspect`、`cancelInstall` 以及 `plugin-manager/changed`、`plugin-manager/install-log`、`plugin-manager/install-state` 事件加入管理器的 Remote;`listBundles` 携带标题、行与覆盖项;`ChangeResult` 增加 `cancelled`、`bundle` 与 `packageResult.kind`;配置增加 `pnpmCommand` 与 `inspectTimeoutMs`。对话框是围绕同一张主题卡的四个画面。
|
||||
`inspect`、`cancelInstall` 以及 `plugin-manager/changed`、`plugin-manager/install-log`、`plugin-manager/install-state` 事件加入管理器的 Remote;`listBundles` 携带标题、行与覆盖项;`ChangeResult` 增加 `cancelled`、`bundle` 与 `packageResult.kind`;配置增加 `pnpmCommand`、`inspectTimeoutMs` 与 `githubConnectionTimeoutMs`。对话框是围绕同一张主题卡的四个画面。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/boot/plugin-manager/tests/install-spec.spec.ts` 钉住 spec 形式与失败分类器的输入;`manager.spec.ts` 用桩住的注册表查询和真实目录驱动 `inspect`,流式转发一次运行,停下一次运行并检查恢复后的文件,并检查变更事件;`operations.spec.ts` 覆盖注册表查询。`packages/client/ui-plugin-manager/tests` 覆盖 store 的阶段、经宿主确认的取消、装后启用、toast 与页面的四个画面;`apps/web/tests/plugin-manager.e2e.ts` 经真实宿主拒绝已装名字、不存在的路径和坏名字,并实时切换一个组合包及其中一行,`plugin-install-cancel.e2e.ts` 从对话框停下一个真实子进程、检查恢复后的文件,并在第二次尝试时装成,`plugin-install-approve.e2e.ts` 用假 pnpm 把脚本留作未决、从对话框允许后在重试中装上。
|
||||
|
||||
`github-connection.spec.ts` 用真实 Git 和测试自有的本机网络端点覆盖立即失败、超时、取消与非交互认证。`manager.spec.ts` 还通过真实 Git 与 pnpm 安装本地仓库,覆盖 HTTPS、GitHub 简写和 SSH 地址,用自有代理隔离 pnpm 的 HTTPS 请求并验证其 SSH 回退。`plugin-install-github.e2e.ts` 检查连接失败及超时弹窗、子进程清理,以及切换镜像时不重试原 GitHub 地址。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md
|
||||
2026-09-18-chat-navigation-performance.md: d990c0cb9a351ea3b3224ce5feec912c176a5fc9
|
||||
2026-09-18-chat-navigation-performance.zh.md: 1a330d3b6bf6357199eb09ffc1cdb434cda1c1af
|
||||
2026-09-18-chat-navigation-performance.md: d82c68098fc422abb181ed4e5e1ca41189cd0951
|
||||
2026-09-18-chat-navigation-performance.zh.md: 9c2f81a51ce53660aabf2dd1e729d02e8028b7b6
|
||||
@@ -22,6 +22,8 @@ The rail uses ResizeObserver-delivered viewport dimensions and fixed item sizes.
|
||||
|
||||
[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) owns follow-tail policy, semantic restoration, and sampled reader movement. [ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) owns loaded or unloaded jumps, paging anchors, replacement tasks, and fallback. [useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) supplies committed inputs and coordinates these owners. Timer and animation-frame completion invoke the owners directly instead of incrementing React tick state. The [pinned-delivery rule](../../../../packages/client/ui-chat/README.md#scroll-ownership) remains independently necessary: small reader gestures cannot be erased by intervening layout growth.
|
||||
|
||||
Shared nested-follow mechanics and control placement are recorded in the [Chat scroll and footer decision](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.md); sampled outer reading and historical navigation retain the owners above.
|
||||
|
||||
### One publication per historical jump
|
||||
|
||||
[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) retains accepted pages and a private paging cursor until its shared target is covered or loading ends. It reverses the page list and flattens it once, then publishes one ordered prepend. Live events continue through their normal publication path. A later page failure publishes the successful prefix once; stream or window replacement discards obsolete buffered pages. Single-page `loadOlder` remains immediate, and repeated jump requests retain the existing lowest-target policy.
|
||||
|
||||
@@ -22,6 +22,8 @@ Status: implemented
|
||||
|
||||
[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) 负责跟随底部、语义恢复和读者移动采样。[ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) 负责已加载或未加载轮次跳转、分页锚点、任务替换和回退。[useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) 提供已提交输入并协调各对象。定时器与动画帧完成时直接调用负责对象,不再递增 React tick 状态。[贴底事件规则](../../../../packages/client/ui-chat/README.zh.md#scroll-ownership) 仍有独立必要性:期间发生的布局增长不能抵消读者的小幅操作。
|
||||
|
||||
共用的嵌套跟随机制与控件定位见 [Chat 滚动与页脚决策](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md);外层采样阅读与历史导航仍由上述对象负责。
|
||||
|
||||
### 每次历史跳转统一发布
|
||||
|
||||
[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) 在共享目标被覆盖或加载结束前,保留已接受的页面和私有分页游标。它只反转一次页面列表并展开一次,然后发布一次有序前插。实时事件继续走正常发布路径。后续页面失败时,已成功取得的部分统一发布一次;流或窗口被替换时丢弃失效的暂存页面。单页 `loadOlder` 仍立即发布,重复跳转请求保留既有的最低目标策略。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md
|
||||
2026-09-19-remote-duplex-stream.md: 0f1f6a575f6871d37822c5829fe4531261b8e95b
|
||||
2026-09-19-remote-duplex-stream.zh.md: d3982aa433aa3ce84b945bb5831cef8064472853
|
||||
2026-09-19-remote-duplex-stream.md: ea8e8cebcc8285c97ebdb70f4e37805d3ce8bfe9
|
||||
2026-09-19-remote-duplex-stream.zh.md: 2e119bf054e20d076e22758178b5e0168092be18
|
||||
@@ -276,6 +276,12 @@ Parsing rules: `item` has exactly the keys `type` and `streamId` plus an optiona
|
||||
| `item` received after `end` | Any | The stream fails with a `gateway/protocol` error frame and aborts; the socket stays open |
|
||||
| `item` / `end` with an unknown `streamId` | Any | Ignored, like `cancel`: after the Host ends a stream and deletes its id, uplink frames still in flight from the Client are normal and must not take down the other streams on the same socket |
|
||||
|
||||
### Cancellation wait lifetime
|
||||
|
||||
`cancellableStream`, `UplinkDecoder.next()`, and the Client uplink pump each create a cancellation or stop promise per read. `Promise.race` does not remove the losing input's reaction when a read completes: reusing a stream-wide pending promise retains completed races and their item results until that promise settles or becomes unreachable. Read-local promises keep retained cancellation state independent of the number of delivered items.
|
||||
|
||||
The Host downlink clears its current reject callback after each read and checks cancellation before reading again. The uplink decoder retains only pending reads: abort rejects them, while normal close resolves all of them with `done`, including overlapping reads. The Client pump drops its stop promise after each read and does not start another read after stopping. Source iterators retain their existing release ownership; closing the Host uplink still precedes returning the method iterator.
|
||||
|
||||
### Ordering guarantees
|
||||
|
||||
Frames on one socket arrive in total order: a logical stream's `open`, `item`…, `end` reach the Host in send order, and uplink items enter `uplink()` in the order of `send`. There is no cross-direction ordering guarantee between uplink and downlink; a protocol that needs request-response pairing carries its own sequence numbers.
|
||||
@@ -538,6 +544,10 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A shared cancellation controller for all three reading loops.** Each loop already has an owner with distinct close behavior. Read-local promises bound retained state without moving those responsibilities into a new abstraction.
|
||||
|
||||
**Requiring source iterators to cancel their own `next()`.** Gateway accepts arbitrary iterables, whose pending reads need not respond to cancellation or `return()`. Gateway must still wake its own waiters independently of source cleanup.
|
||||
|
||||
**A separate `mode: 'duplex'` plus a reserved parameter `uplink: AsyncIterable<In>` alongside `signal`.** Special logic everywhere: the analyzer must recognize a second reserved parameter and its position rules, the generated signature gains a parameter, load time must validate `duplex ⟺ uplink`, and because the mux does not know the method mode, the Gateway must reject uplink frames on behalf of `'stream'` methods. Duplex is an inherent property of the transport-layer stream, not a mode of the method: the carrier already has `streamId`, `open`, and `cancel`; the uplink adds just two more frames, which any stream can receive and which are simply not sent when unused.
|
||||
|
||||
**Declaring the uplink type in a decorator option, `@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`.** A string reference to a type name is not checked by the compiler and breaks on rename. The return-type alias puts both types in one compiler-visible place.
|
||||
@@ -572,6 +582,8 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
## Testing
|
||||
|
||||
Retention cases in `gateway-stream.host.spec.ts` and `gateway.client.spec.ts` count live iterator results after a full GC while the streams remain open. Lifecycle cases cover normal close waking multiple pending uplink reads, cancellation between reads, synchronous abort followed by a source exception, and termination during a Client send.
|
||||
|
||||
- `packages/api/gateway/tests/echo-stream.host.spec.ts`: the same echo method passes through the WebSocket carrier and through the in-process carrier.
|
||||
- Gateway Host: exact-key validation in frame parsing; `UplinkInbox` buffering during opening, `item` after `end`, overflow, ignored unknown `streamId`, and cleanup on socket closure; `UplinkDecoder` delivery with and without a codec and abort on decode failure; `uplink()` taken only once; the `extend({ invocation })` view and the origin of `peer`; `cancellableStream` closing the uplink before `return()`.
|
||||
- Gateway Client: the handle's `send` (JSON validation, queued before open, throws after termination), idempotent `end`, `dispose` driving the `cancel` frame and ending later reads; the pump's three exits; the pump stopping as soon as a terminal frame arrives.
|
||||
|
||||
@@ -276,6 +276,12 @@ export type RemoteStreamServerMessage =
|
||||
| `end` 之后又收到 `item` | 任意 | 该流以 `gateway/protocol` 错误帧失败并中止;socket 不关闭 |
|
||||
| 未知 `streamId` 的 `item` / `end` | 任意 | 忽略,与 `cancel` 相同:Host 结束流并删除 id 后,客户端在途的上行帧属正常现象,不能连累同一 socket 上的其他流 |
|
||||
|
||||
### 取消等待的生命周期
|
||||
|
||||
`cancellableStream`、`UplinkDecoder.next()` 和 Client 上行泵各自为单次读取创建取消或停止 Promise。读取完成后,`Promise.race` 不会移除另一输入上的 reaction:复用流级未完成 Promise 会保留已完成的竞争及其读取结果,直到该 Promise 完成或不可达。单次读取的 Promise 使取消状态的保留量不随已交付项数增长。
|
||||
|
||||
Host 下行在每次读取后清除当前拒绝回调,并在再次读取前检查取消状态。上行解码器只保留尚未完成的读取:取消时拒绝这些读取,正常关闭则以 `done` 唤醒全部读取,包括重叠的读取。Client 泵在每次读取后移除停止 Promise,停止后不再发起读取。源迭代器的释放仍由原有所有者负责;Host 仍先关闭上行,再返回方法迭代器。
|
||||
|
||||
### 顺序保证
|
||||
|
||||
同一条 socket 上的帧全序到达:一条逻辑流的 `open`、`item`…、`end` 按发送顺序到达 Host;上行项进入 `uplink()` 的顺序就是 `send` 的顺序。上行与下行之间没有跨方向顺序保证;需要请求应答配对的协议自带序号。
|
||||
@@ -538,6 +544,10 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**三处读取循环共用一个取消控制器。** 每处循环已有自己的所有者和不同的关闭行为。单次读取的 Promise 能限制保留状态,不需要把这些职责迁入新抽象。
|
||||
|
||||
**要求源迭代器自行取消 `next()`。** Gateway 接受任意 iterable,其待处理读取不一定响应取消或 `return()`。Gateway 仍需独立于源清理来唤醒自己的等待者。
|
||||
|
||||
**独立的 `mode: 'duplex'` 加一个与 `signal` 同级的保留参数 `uplink: AsyncIterable<In>`。** 每一处都是特殊逻辑:analyzer 要认第二个保留参数及其位置规则,生成签名多一个参数,加载期要校验 `duplex ⟺ uplink`,mux 不知道方法模式所以网关要替 `'stream'` 方法拒绝上行帧。双工是传输层流的固有性质,不是方法的一种模式:载体本来就有 `streamId`、`open`、`cancel`,上行只是再加两种帧,任何流都能收,不用就不发。
|
||||
|
||||
**用装饰器选项声明上行类型,`@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`。** 字符串引用类型名不受编译器约束,重命名即断。返回类型别名把两个类型写在同一个编译器可见的位置。
|
||||
@@ -572,6 +582,8 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
## 测试
|
||||
|
||||
`gateway-stream.host.spec.ts` 和 `gateway.client.spec.ts` 中的保留对象用例在流保持打开时进行全量 GC,再统计存活的迭代结果。生命周期用例覆盖正常关闭唤醒多个待处理上行读取、两次读取之间的取消、源同步取消后抛错,以及 Client 发送期间流结束。
|
||||
|
||||
- `packages/api/gateway/tests/echo-stream.host.spec.ts`:同一个 echo 方法分别穿过 WebSocket 载体与进程内载体。
|
||||
- 网关 Host:帧解析的精确键校验;`UplinkInbox` 的 opening 缓冲、`end` 后 `item`、超限、未知 `streamId` 忽略、socket 关闭时的收尾;`UplinkDecoder` 有无 codec 两种交付与解码失败中止;`uplink()` 只能取一次;`extend({ invocation })` 视图与 `peer` 的来源;`cancellableStream` 先关 uplink 再 `return()`。
|
||||
- 网关 Client:句柄的 `send`(JSON 校验、open 前入队、终止后抛错)、`end` 幂等、`dispose` 驱动 `cancel` 帧并结束后续读取;泵的三种退出;终止帧到达即停泵。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md
|
||||
2026-09-21-conversation-build-groups.md: 7c02de8dd0aaf842690806ea67ddaf5d4561fc4e
|
||||
2026-09-21-conversation-build-groups.zh.md: 4f2b331596cacdd63d902c9db73931d65ac589ad
|
||||
2026-09-21-conversation-build-groups.md: bccb48b0e40a076bbcb59aaca9e9ef6208e9469d
|
||||
2026-09-21-conversation-build-groups.zh.md: 5d2ec9e684c75b3db3568c4f046129431295f736
|
||||
@@ -35,6 +35,7 @@ The grouping foundation provides a separate, Node-input `ConversationGroupDefini
|
||||
| Record | Relationship |
|
||||
|---|---|
|
||||
| [Business Node assembly](2026-08-09-client-conversation-node-assembly.md) | Retains event matching, Contexts, Locations, one business Node per Context, and target Builders; grouping adds another input category. |
|
||||
| [Chat scroll and footer](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.md) | Owns clipping and independent nested following without changing Group Definition membership. |
|
||||
|
||||
Grouping preserves the independent Node-assembly decisions. Cross-View navigation and `toolCallFocus` remain separate responsibilities; Group references carry no View handles or resource-navigation policy.
|
||||
|
||||
@@ -106,7 +107,7 @@ Grouping never replays raw events. Node Definitions handle replay first; groupin
|
||||
| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | Atomic reference validation, keyed sources, array reuse, and local publication. |
|
||||
| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | Record projected Node deltas, target positions, and changed Turn orders; provide indexed readers and defer source notification. No process grouping class. |
|
||||
| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | Read optional root entries and switch between node/group; fallback to existing Node order. |
|
||||
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | Stable group parent, member-only subscription, nested Node seats. |
|
||||
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | Stable group parent, group-local subscriptions, nested Node seats. |
|
||||
| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | Existing Node sources/renderers, groupPart forwarding, distinct part anchors, Store-replacement rebind. |
|
||||
| [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) and [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | Existing keyed-hook injection for Group sources; no Slot-engine change. |
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat
|
||||
| 记录 | 关系 |
|
||||
|---|---|
|
||||
| [业务节点组装](2026-08-09-client-conversation-node-assembly.zh.md) | 保留事件匹配、Context、Location、每个 Context 一个业务 Node 及目标 Builder,分组增加另一种输入类别。 |
|
||||
| [Chat 滚动与页脚](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md) | 负责裁剪与独立的嵌套跟随,不改变 Group Definition 成员关系。 |
|
||||
|
||||
分组保留独立的节点组装决策。跨 View 导航与 `toolCallFocus` 仍是独立职责,Group 引用不携带 View 句柄或资源导航策略。
|
||||
|
||||
@@ -106,7 +107,7 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价
|
||||
| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | 原子引用校验、按键来源、数组复用及局部发布。 |
|
||||
| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | 记录投影后节点增量、目标位置及变化轮次顺序,提供索引读取器并推迟来源通知,不持有过程分组类。 |
|
||||
| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | 读取可选根引用并切换 node/group,无分组时使用既有 Node 顺序。 |
|
||||
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | 稳定组父级、仅成员订阅及嵌套 Node 容器。 |
|
||||
| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | 稳定组父级、组内订阅及嵌套 Node 容器。 |
|
||||
| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | 既有 Node 来源及渲染器、groupPart 传递、独立部分锚点及 Store 替换时重绑定。 |
|
||||
| [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) 与 [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | 使用现有按键钩子注入 Group 来源,不改 Slot 引擎。 |
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-22-fatal-diagnostics-and-crash-reports.md
|
||||
2026-09-22-fatal-diagnostics-and-crash-reports.md: 01cbb24fc84b19465a15e9f82c03ae87621b5fe9
|
||||
2026-09-22-fatal-diagnostics-and-crash-reports.zh.md: 38f6a6802b61b128ab6b8ffda0465691c5244523
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Agent Note: Fatal diagnostics and Desktop crash reports
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-22-fatal-diagnostics-and-crash-reports.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Two field failures of the Desktop application were diagnosed from screenshots of the fatal recovery dialog, which shows the last eight lines of the error and used to promise that the full diagnostic was in the Electron console. A packaged installation never surfaces that console.
|
||||
|
||||
On Windows, `OutputCollector.spillAll` in `dsh-subprocess-local` opened its spill file inside the child's stdout `'data'` listener. The `openSync` with `'wx'` threw `ENOENT`, which for that flag means the private per-process spill directory was gone (it is created once and never recreated; what removed it was not observed), and because no process-level handler existed the Desktop Host died with Node's default uncaught-exception output. The `path:` property happened to land in the eight visible lines; nothing else about the failure was recoverable.
|
||||
|
||||
On macOS, the boot audit listed a set of Web client rows as `import failed (see console for the import error)` while the real import error stayed in the renderer console. The eight visible names match exactly the rows that depend on one application batch, so a single failed load of that batch script explains the screenshot; whether the failure was a transport error, a stale-URL 404, or a truncated response was not determinable. A batch was requested exactly once, so any transient failure was final.
|
||||
|
||||
Both cases shared three gaps: fatal diagnostics were not persisted, the Host had no `uncaughtException` policy, and a single fallible step was allowed to fail a whole feature.
|
||||
|
||||
## Decision
|
||||
|
||||
Four decisions ship together.
|
||||
|
||||
**Uncaught exceptions are fatal and reported; the failed operation is never resumed.** `installFailLoud` in `dsh-app-boot` registers the same handler for `'uncaughtException'` as for `'unhandledRejection'`: write one labelled `util.inspect` diagnostic to stderr, await the surface's release hook under the existing timeout, exit 1. Control never returns to the failed operation and the event loop runs only until the release settles or times out, because only the throw site knows which state is intact; a `'data'` listener that threw mid-update has already dropped a chunk and half-set its own fields, and resuming would turn a visible crash into silently wrong output. `util.inspect` replaces `err.stack` because a `node:fs` error's `code`, `syscall`, and `path`, and any `cause` chain, are enumerable properties the stack line omits. The rejection label `fatal load failure` is unchanged because the Web profile expected-output e2e tests match it; exceptions use `fatal uncaught exception`.
|
||||
|
||||
**Spill files are best-effort at every step.** `spillPath` was already optional and a failed final close already withheld it. `spillAll` now contains every filesystem failure while opening or appending: it discards the spill, reports once through the owner's logger (`SpillOptions.onFailure`, wired to `ctx.logger.error` by the runtime and the SSH helper), and leaves the in-memory tail collecting. The removed directory is not recreated: the security of the design rests on a random name created once by this process, and recreating a known name would give that up. A fresh random directory on `ENOENT` is deferred work.
|
||||
|
||||
**A fatal Desktop failure writes a crash report before its dialog.** Every `reportFatal` caller names its source (`host`, `web-boot`, `renderer`, `main`). `DesktopFatalRecovery.report` awaits `writeCrashReport` for at most one second, then shows the dialog with the file path on its own line, independent of whether the error was shortened and in the listener-conflict variant too. The report holds the source, whether the backend had reached ready, application and runtime versions, the complete inspected error (the backend state carries the original failure object, and a Host startup failure carries the Host's own inspected error over the fatal IPC event, because the Host's stderr and that message race and the shell reports the first failure it sees), and the primary window's recent error-level console output captured from `webContents` `'console-message'` (a 64 KiB tail). Reports live under `app.getPath('logs')`, are owner-only where the platform allows, and startup keeps the ten newest. A fatal failure during shutdown writes a report without a dialog. Buttons and recovery actions are unchanged.
|
||||
|
||||
**A failed batch script is a per-row problem, not a boot failure.** In `ClientModuleSystem.arrive`, a transport failure (the script `error` event; nothing executed) is retried once on the same URL, shared by every row waiting on that batch. A script that loaded without registering a row is never re-executed: the batch registers packages in sequence and `register()` rejects duplicates, so a replay would stop at the first package the original run did register. Every executed batch URL is remembered, so this holds even when the first importer from the batch was a row it did register and a later row finds itself missing. Either way each still-missing row then loads its own one-resource combo URL, which the Host serves for every package; a failed batch URL is remembered so later rows skip to their fallback, while a one-resource URL stays retryable across imports (within one boot the Loader imports each entry once, so a missing row gets one fallback attempt). Dependency failures are wrapped with the consumer and dependency names. The module system records the last `import()` or `prefetch()` failure per row at the outer operation, so factory execution errors are captured as well as arrival errors, and `assertEntriesActive` reports that text per fiberless entry.
|
||||
|
||||
## Relationship to earlier decisions
|
||||
|
||||
This partially supersedes the [native Desktop fatal recovery](2026-09-15-desktop-native-fatal-recovery.md) note: the crash report file, not the Electron console, is now the record the dialog points to, and the dialog detail carries the report-path line; that note's dialog ownership, button set, and profile recovery remain active. The `uncaughtException` handling extends the release mechanism of the [fail-loud release](../bug-fix/2026-07-31-fail-loud-releases-the-terminal.md) note to synchronous throws.
|
||||
|
||||
## Relationship to the Desktop logging design
|
||||
|
||||
The Desktop local logging design (a Worker-owned rolling JSONL log with a closed event vocabulary, redaction, and rate limits) is a continuous-log design and remains future work. It stated that Host stderr and renderer console output are never persisted and that errors reach the log only as fixed categories. This decision carves out one exception: the one-time crash report of a fatal failure keeps the raw error and the retained stderr and console tails, because that raw content is what both field diagnoses needed and the report is bounded (the error section is cut at 256 KiB, the Host diagnostic at 64 KiB, the renderer console tail at 64 KiB), local, owner-only, and written only when the application is already showing its fatal dialog. The dialog names the file so a user knows what they would be sharing. The continuous-log privacy posture is unchanged for anything that is not a crash report.
|
||||
|
||||
## Testing
|
||||
|
||||
- `dsh-app-boot`: uncaught exceptions produce the labelled diagnostic with enumerable properties and the cause chain, share the exit latch and release timeout with rejections, and both handlers uninstall.
|
||||
- `dsh-subprocess-local`: `ENOENT` on open (removed directory), `ENOTDIR` on open, and `ENOSPC` on append after the file exists each leave the tail byte-exact, withdraw the spill file, report once, and never throw.
|
||||
- `dsh-client-modules`: a transport failure retries once and shares the retry; a persistent failure falls back per missing row without re-fetching the batch per row; a batch that registered nothing or only some rows is not re-executed; a one-resource URL stays retryable; the final error lists every attempt; import errors are recorded for transport, factory, and dependency failures and cleared by success and invalidation.
|
||||
- `dsh-client-web`: the boot audit names the recorded import error when the module system is supplied.
|
||||
- Desktop: `crash-report` unit tests cover rendering, owner-only writing, write failure, pruning that leaves other files alone, and the console tail; `fatal-recovery` tests pin the dialog text with and without a report path in both locales, the one-second bound, and a single write per fatal; the startup harness asserts the report input for a running-phase Host exit including the captured renderer console.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Host now exits through one path for both failure kinds, up to the release timeout later than Node's immediate default exit, so a fatal dialog can appear up to two seconds after the failure, and plugin stderr written during that release window can push the fail-loud line out of the dialog's eight-line excerpt (the report file keeps the 64 KiB tail). Spill failures are no longer visible to the user beyond a truncated result with no path; the plugin logger line is the only trace, which the Host-side continuous log design would persist. Crash reports persist raw stderr and console tails, accepted for the reasons above. A batch load failure now costs up to three requests per missing row and never fails registered rows; the shared-batch memory means a later import of a row from a failed batch goes straight to its one-resource URL for the life of the page.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Catch the uncaught exception and keep the Host running.** Rejected: the state after a listener throws is unknown to anything but the throw site. In the Windows case, continuing would have dropped the chunk, advertised a spill path to a file that did not exist, and thrown again on every following chunk.
|
||||
|
||||
**Recreate the spill directory on `ENOENT`.** Rejected for this change: recreating a known name gives up the random-directory property the design exists for. A fresh `mkdtemp` on failure needs the collector to hold a directory provider rather than a string and is deferred.
|
||||
|
||||
**Write the crash report synchronously so the dialog stays synchronous.** Rejected: a hung write on a slow or network-mounted profile directory would freeze the shell with no dialog at all. The asynchronous write with a one-second bound shows the dialog either way.
|
||||
|
||||
**Persist only classified error categories in the crash report, as the continuous-log design prescribes.** Rejected for crash reports: the `path:` property and the renderer's `bundle script … failed to load` line were the load-bearing evidence in both diagnoses and would have been reduced to `UNCLASSIFIED`.
|
||||
|
||||
**Retry the batch URL for every failure kind.** Rejected: re-executing a batch that already registered some packages throws `duplicate factory registration` inside the script, the `load` event still fires, and the caller sees the same "loaded without registering" it started with, unable to tell that the retry caused it.
|
||||
|
||||
**Keep more generations of Host batch URLs so a recomposed graph does not 404 an in-flight boot.** Deferred rather than shipped: no production trigger for a post-ready graph change was found, `lazyBody` caches response buffers so retention has a real memory cost, and the generation and age limits are deployment tunables that need `Config` fields.
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
# Agent Note: 致命诊断与 Desktop 崩溃报告
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-22-fatal-diagnostics-and-crash-reports.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Desktop 应用的两起用户侧故障都是靠致命恢复对话框的截图定位的。该对话框只显示错误的最后八行,并且当时声称完整诊断已写入 Electron 控制台,而打包安装的应用从不暴露那个控制台。
|
||||
|
||||
Windows 上,`dsh-subprocess-local` 的 `OutputCollector.spillAll` 在子进程 stdout 的 `'data'` 监听器内打开 spill 文件。带 `'wx'` 的 `openSync` 抛出 `ENOENT`,对该标志而言这意味着每进程私有 spill 目录已不存在(它只创建一次、从不重建;是什么删除了它没有被观测到),由于没有任何进程级 handler,Desktop Host 以 Node 默认的未捕获异常输出退出。`path:` 属性恰好落在可见的八行里;此外关于该故障的信息一无所获。
|
||||
|
||||
macOS 上,启动审计把一组 Web 客户端行列为 `import failed (see console for the import error)`,而真正的 import 错误只留在渲染进程 console 中。可见的八个名字与依赖同一个 application batch 的行完全吻合,因此该 batch 脚本的一次加载失败可以解释截图;失败是传输错误、过期 URL 的 404 还是响应截断,无法判定。batch 只请求一次,因此任何瞬态失败都是最终结果。
|
||||
|
||||
两起事故共享三个缺口:致命诊断没有持久化、Host 没有 `uncaughtException` 策略、一个可失败步骤被允许拖垮整个功能。
|
||||
|
||||
## Decision
|
||||
|
||||
四条决定一并交付。
|
||||
|
||||
**未捕获异常是致命的并会被报告;失败的操作绝不恢复。** `dsh-app-boot` 的 `installFailLoud` 为 `'uncaughtException'` 注册与 `'unhandledRejection'` 相同的 handler:向 stderr 写一条带标签的 `util.inspect` 诊断,在既有超时内等待界面的 release 钩子,以 1 退出。控制流不会回到失败的操作,事件循环只运行到 release 结束或超时,因为只有抛出点知道哪些状态仍然完整;在更新过程中抛出的 `'data'` 监听器已经丢掉了一块数据并只设置了一半自己的字段,恢复运行会把可见的崩溃变成静默错误的输出。用 `util.inspect` 替代 `err.stack`,因为 `node:fs` 错误的 `code`、`syscall`、`path` 以及 `cause` 链都是堆栈行省略的可枚举属性。rejection 的标签 `fatal load failure` 保持不变,因为 Web profile 的 expected-output e2e 测试匹配它;异常使用 `fatal uncaught exception`。
|
||||
|
||||
**spill 文件在每一步都是尽力而为。** `spillPath` 本来就是可选的,最终关闭失败时已经会撤回它。`spillAll` 现在包住打开或追加过程中的每一种文件系统失败:丢弃该 spill,通过所有者的 logger 报告一次(`SpillOptions.onFailure`,由运行时和 SSH helper 接到 `ctx.logger.error`),并让内存尾部继续收集。被删除的目录不会重建:该设计的安全性依赖于由本进程一次性创建的随机名称,重建一个已知名称就放弃了这一点。在 `ENOENT` 时新建随机目录是待办工作。
|
||||
|
||||
**Desktop 致命失败在弹窗前写入崩溃报告。** 每个 `reportFatal` 调用方都标明来源(`host`、`web-boot`、`renderer`、`main`)。`DesktopFatalRecovery.report` 最多等待 `writeCrashReport` 一秒,然后弹出对话框并把文件路径单独占一行——与错误是否被截短无关,端口占用变体同样如此。报告包含来源、后端是否已就绪、应用与运行时版本、完整的 inspect 错误(backend 状态携带原始错误对象;Host 启动失败通过 fatal IPC 事件携带 Host 自己的 inspect 错误,因为 Host 的 stderr 与该消息存在竞争,而壳只报告它看到的第一个失败),以及从 `webContents` 的 `'console-message'` 捕获的主窗口最近 error 级 console 输出(64 KiB 尾部)。报告位于 `app.getPath('logs')` 下,平台允许时仅所有者可读,启动时保留最新十份。关闭过程中的致命失败只写报告、不弹窗。按钮与恢复操作不变。
|
||||
|
||||
**batch 脚本失败是按行的问题,不是启动失败。** 在 `ClientModuleSystem.arrive` 中,传输失败(脚本 `error` 事件;什么都没执行)在同一 URL 上重试一次,等待该 batch 的所有行共享这次重试。加载成功但没有注册某行的脚本绝不重新执行:batch 按顺序注册各包,`register()` 拒绝重复,重放会在原次运行已注册的第一个包处停止。每个已执行的 batch URL 都会被记住,因此即使首个从该 batch 导入的是它已注册的行、之后另一行发现自己缺失,这一点仍然成立。两种情况下每条仍缺失的行随后加载自己的单资源 combo URL,Host 为每个包都提供该 URL;失败的 batch URL 会被记住,后续行直接走回退,而单资源 URL 跨 import 保持可重试(一次启动内 Loader 对每个条目只 import 一次,因此缺失行只有一次回退尝试)。依赖失败用消费者与依赖的名字包装。模块系统在最外层操作按行记录最后一次 `import()` 或 `prefetch()` 失败,因此 factory 执行错误与到达错误一样被捕获,`assertEntriesActive` 按无 fiber 的条目报告该文本。
|
||||
|
||||
## 与既有决定的关系
|
||||
|
||||
本 note 部分取代 [Desktop 原生致命恢复](2026-09-15-desktop-native-fatal-recovery.zh.md):对话框指向的记录现在是崩溃报告文件而非 Electron 控制台,对话框详情多出报告路径一行;该 note 对对话框归属、按钮集合与 profile 恢复的决定仍然有效。`uncaughtException` 的处理把 [fail-loud release](../bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md) note 的 release 机制扩展到同步抛出。
|
||||
|
||||
## 与 Desktop 日志体系设计的关系
|
||||
|
||||
Desktop 本地日志设计(Worker 独占写入的滚动 JSONL 日志、封闭事件词汇、脱敏与限流)是持续日志设计,仍是未来工作。它规定 Host stderr 与渲染进程 console 输出永不持久化、错误只以固定分类进入日志。本决定划出一个例外:致命失败的一次性崩溃报告保留原始错误以及保留的 stderr 与 console 尾部,因为两次现场定位需要的正是这些原始内容,而报告有界(error 段截至 256 KiB、Host 诊断 64 KiB、渲染进程 console 尾部 64 KiB)、本地、仅所有者可读,且只在应用已经弹出致命对话框时写入。对话框标明文件名,用户知道自己会分享什么。任何非崩溃报告的内容仍遵循持续日志的隐私姿态。
|
||||
|
||||
## Testing
|
||||
|
||||
- `dsh-app-boot`:未捕获异常产生带可枚举属性与 cause 链的带标签诊断,与 rejection 共享退出闩锁和 release 超时,两个 handler 都能卸载。
|
||||
- `dsh-subprocess-local`:打开时 `ENOENT`(目录被删)、打开时 `ENOTDIR`、文件已存在后追加时 `ENOSPC`,三者都保持尾部逐字节一致、撤回 spill 文件、报告一次且不抛出。
|
||||
- `dsh-client-modules`:传输失败重试一次并共享重试;持续失败按缺失行回退且不重复请求 batch;零注册或部分注册的 batch 不重新执行;单资源 URL 保持可重试;最终错误列出每次尝试;传输、factory 与依赖失败都被记录,成功和失效会清除记录。
|
||||
- `dsh-client-web`:提供模块系统时启动审计写出记录的 import 错误。
|
||||
- Desktop:`crash-report` 单测覆盖渲染、仅所有者可读的写入、写入失败、不触碰其他文件的清理以及 console 尾部;`fatal-recovery` 测试在两种语言下钉住带与不带报告路径的对话框文本、一秒上限以及每次致命失败只写一次;启动夹具断言运行阶段 Host 退出的报告输入,包括捕获的渲染进程 console。
|
||||
|
||||
## Consequences
|
||||
|
||||
Host 现在对两种失败走同一条退出路径,比 Node 立即退出的默认行为最多晚 release 超时的时间,因此致命对话框可能在失败后最多两秒才出现,且 release 窗口内插件写到 stderr 的内容可能把 fail-loud 那一行挤出对话框的八行摘录(报告文件保留 64 KiB 尾部)。spill 失败对用户不再可见,只剩一个带截断标记且没有路径的结果;插件 logger 那一行是唯一痕迹,Host 侧持续日志设计将来会持久化它。崩溃报告持久化原始 stderr 与 console 尾部,理由如上。batch 加载失败现在对每条缺失行最多花费三次请求,且绝不让已注册的行失败;共享的 batch 记忆意味着页面生命周期内,失败 batch 中某行的后续 import 会直接走其单资源 URL。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**捕获未捕获异常并让 Host 继续运行。** 否决:监听器抛出后的状态除抛出点外无人知晓。在 Windows 案例中,继续运行会丢掉该块数据、向模型宣告一个不存在文件的 spill 路径,并在之后每一块上再次抛出。
|
||||
|
||||
**在 `ENOENT` 时重建 spill 目录。** 本次否决:重建已知名称放弃了该设计赖以存在的随机目录属性。失败时新建 `mkdtemp` 需要收集器持有目录提供函数而非字符串,作为待办。
|
||||
|
||||
**同步写崩溃报告以保持对话框同步。** 否决:在缓慢或网络挂载的 profile 目录上,一次挂起的写入会让壳冻结且完全没有对话框。带一秒上限的异步写入无论如何都会弹出对话框。
|
||||
|
||||
**按持续日志设计的规定,崩溃报告只持久化分类后的错误类别。** 对崩溃报告否决:`path:` 属性和渲染进程的 `bundle script … failed to load` 那一行是两次定位的关键证据,按该规则会被抹成 `UNCLASSIFIED`。
|
||||
|
||||
**对每种失败都重试 batch URL。** 否决:重新执行一个已注册部分包的 batch 会在脚本内抛出 `duplicate factory registration`,`load` 事件照常触发,调用方看到的还是最初那个「loaded without registering」,无法分辨是重试造成的。
|
||||
|
||||
**保留更多代 Host batch URL,使重组的 graph 不会让进行中的启动收到 404。** 推迟而非交付:没有找到 ready 之后改动 graph 的生产触发者,`lazyBody` 缓存响应 Buffer 使保留有实际内存成本,代数与时限是需要 `Config` 字段的部署可调项。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md
|
||||
2026-09-22-tool-call-three-phases.md: f43eaf1a685358f251a446889b6ac25f5fdf0144
|
||||
2026-09-22-tool-call-three-phases.zh.md: accbd2e966595ae7f124138147d191f97cb763ee
|
||||
@@ -0,0 +1,67 @@
|
||||
# Agent Note: Tool calls own preparation, dispatch, and results by callId
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-22-tool-call-three-phases.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The model often provides a tool name and call ID while it is still generating arguments, but `tool/call` arrives only after the entire Assistant stream ends. When one response contains several calls, an earlier call waits for later calls even after its own arguments are complete. File content is part of the arguments to `write`, so this delay can last several seconds.
|
||||
|
||||
Creating tool nodes only at `tool/call` presents that interval as no tool activity. Putting a preparing call in an Assistant part instead requires the Group to switch references between Assistant and Tool nodes. Preparation and dispatch belong to one tool lifecycle and do not require two cooperating nodes.
|
||||
|
||||
## Decision
|
||||
|
||||
### One Tool owns three stages
|
||||
|
||||
The [Tool Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts) groups calls by callId and supplies explicit stage data to tool components.
|
||||
|
||||
| Stage | Creation or update evidence | Available data and presentation |
|
||||
|---|---|---|
|
||||
| `preparing` | A live delta with a call ID and tool name | Call identity, name, and time; no complete arguments, and one non-expandable row. An opt-in Hook can read the raw argument prefix. |
|
||||
| `start` | `tool/call` | Complete arguments; enables the tool's existing call presentation. |
|
||||
| `result` | `tool/result` | The result and any complete arguments paired within the loaded window. |
|
||||
|
||||
An argument block ending does not trigger dispatch presentation. Showing preparation changes neither model requests, tool dispatch timing, Session events, nor the persisted format. An empty argument string does not stand in for a dispatched call.
|
||||
|
||||
### Start marks an initialization candidate
|
||||
|
||||
The [Conversation Definition](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) retains its match/start/update interface and existing roles. Durable events and transient deltas may both match as start. The earliest start in the current event sequence initializes State; every later Match, including another start for the same ID, goes through update.
|
||||
|
||||
A named tool delta and `tool/call` are both initialization candidates. A live delta can create preparation; a historical `tool/call` creates the dispatched stage directly. Matching still reads only the current event, without Context lookup, additional roles, registries, or separate caches.
|
||||
|
||||
Successful stream settlement publishes the final Assistant message while retaining transient deltas until `step/end`. Each `tool/call` updates its existing callId; the Assistant keeps its live ordering anchor until the same cleanup. Failed, interrupted, or abandoned streams retire their deltas immediately. On retirement the Assembler reselects starts and recomputes State from remaining Matches. Undispatched preparations hide without synthetic execution results.
|
||||
|
||||
### Converge on final state without replaying preparation
|
||||
|
||||
Historical pages do not expand settled Assistant messages into live deltas. Reconnection restores only an attempt that is still live. Live presentation may follow preparing → start → result, while history follows start → result directly. Equal durable events produce equal final state; intermediate presentation and preparation-group identity need not match.
|
||||
|
||||
Groups include Tool nodes from preparation onward and classify and count them by tool name. AssistantNodeView does not search tool blocks, and Groups neither encode tool groupPart values nor take over tool lifecycles. Grouping retains the [node-input grouping decision](2026-09-21-conversation-build-groups.md); the Builder does not interpret preparation business rules.
|
||||
|
||||
### Shared rows reuse their existing presentation
|
||||
|
||||
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) reuses the existing icon, title, and row component across all stages. The shared row model selects the tool title and combines any generic tool-name prefix with the available argument summary. Preparation has no complete arguments or result, and the shared argument parser returns null without parsing JSON. The existing slot Hook binding optionally exposes a call-scoped raw prefix; it adds no preparation registration mechanism. ToolRow prevents preparation rows from expanding.
|
||||
|
||||
Read, read_image, write/edit, search, web, todo, question, details, and the generic fallback use this path. Custom rows such as Bash, Skill, Present, and Cordis keep their own preparation branches. No automatic/custom registration declaration or second tool-classification list in ToolTree is introduced.
|
||||
|
||||
### Relationship to existing records
|
||||
|
||||
This decision replaces the [business-node assembly](2026-08-09-client-conversation-node-assembly.md) restriction that starts must be durable and a Context may receive only one start. That record's stable IDs, business Definitions, Contexts, Locations, predecessor dependencies, and target Builder responsibilities remain applicable.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Create nodes only at `tool/call`.** This cannot show a known tool identity while arguments are being generated and leaves a visible wait in multi-call responses.
|
||||
|
||||
**Transfer presentation through Assistant parts and Groups.** A tool would need two presentation sources, additional deduplication, and identity transfer. Lifecycle ownership becomes distributed, and renderers must recover Assistant content from part identifiers.
|
||||
|
||||
**Persist or replay preparing.** Preparation describes current generation; durable calls and results already suffice to reconstruct the final presentation. Reproducing the same intermediate process adds unnecessary state and event interpretation.
|
||||
|
||||
**Add a preparation registration mechanism.** Generic tools already share ToolRow and argument models, including reusable icons and titles. Additional declarations or configuration for this stage would maintain another representation of tool presentation.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A tool may appear before its arguments are complete, but preparation neither implies execution nor exposes interactions requiring arguments.
|
||||
- Multiple starts with one ID belong to one lifecycle. Independent calls must use distinct IDs; a second-start error no longer detects identity reuse.
|
||||
- The shared row primitive is reused across stages. Write/edit separate preparing and dispatched components so only the former subscribes to raw arguments; custom rows may also switch internal components by stage.
|
||||
- Partial JSON parsing and `useToolCallDelta` are not provided. `useToolCallArgumentsPartial` exposes the existing Step's raw prefix without a second accumulator; write/edit use only its length for preparation progress.
|
||||
- Assembly tests separately cover live preparation, withdrawal, active-stream reconstruction, durable history, and paged convergence. Component tests cover argument-free presentation and stage changes; a recorded session covers browser preparation and reload results.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Agent Note: 工具调用按 callId 管理准备、派发与结果
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-22-tool-call-three-phases.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
模型生成工具参数时,工具名和调用 ID 往往已经可用,但 `tool/call` 要等整条 Assistant 流结束后才到达。一次响应包含多个调用时,前一个调用即使已经生成完整参数,仍要等待后续调用。文件正文也属于 `write` 参数,这段等待可能持续数秒。
|
||||
|
||||
只从 `tool/call` 创建工具节点,会把这段时间表现为没有工具活动;把准备中的调用放在 Assistant 片段里,又需要 Group 在 Assistant 与正式 Tool 节点之间切换引用。准备态和正式调用属于同一个工具生命周期,不应由两个节点配合表达。
|
||||
|
||||
## 决策
|
||||
|
||||
### 同一个 Tool 拥有三个阶段
|
||||
|
||||
[Tool Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts) 按 callId 聚合调用,向工具组件提供明确的阶段数据。
|
||||
|
||||
| 阶段 | 创建或更新依据 | 可用数据与展示 |
|
||||
|---|---|---|
|
||||
| `preparing` | 带调用 ID 和工具名的实时 delta | 调用身份、名称和时间;不提供完整参数,只显示不可展开的一行。可选钩子可读取原始参数前缀。 |
|
||||
| `start` | `tool/call` | 完整参数;启用工具原有的调用展示。 |
|
||||
| `result` | `tool/result` | 调用结果,以及当前窗口内可配对的完整参数。 |
|
||||
|
||||
参数块结束不触发派发阶段。展示准备态不改变模型请求、工具派发时机、Session 事件或持久化格式,也不把空参数字符串伪装成正式调用。
|
||||
|
||||
### start 表示可创建入口
|
||||
|
||||
沿用 [Conversation Definition](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) 的 `match/start/update` 接口和既有角色。持久事件与瞬态 delta 都可以匹配为 start;当前事件序列中最早的 start 初始化 State,后续 Match,包括同 ID 的其他 start,都交给 update。
|
||||
|
||||
Tool 的具名 delta 和 `tool/call` 都是创建入口。实时先收到 delta,就从 preparing 创建;历史先收到 `tool/call`,就从 start 创建。匹配函数仍然只读取当前事件,不查询 Context,不增加角色、注册表或旁路缓存。
|
||||
|
||||
成功流结束时发布最终 Assistant 消息,瞬态 delta 保留到 `step/end`。每个 `tool/call` 更新已有 callId;Assistant 的实时排序锚点也保留到同一次清理。失败、中断或废弃的流立即移除 delta。清理时,Assembler 从剩余 Match 重新选择 start 并重算 State;未派发的准备节点隐藏,不合成执行结果。
|
||||
|
||||
### 最终状态收敛,不重现准备过程
|
||||
|
||||
历史分页不把已结束 Assistant 消息展开为实时 delta。重连只恢复仍在生成的活跃流。实时展示可以经历 preparing → start → result,历史回放可以直接经历 start → result;相同持久事件得到相同最终状态即可,不要求中间过程或准备态分组身份一致。
|
||||
|
||||
Group 从准备阶段起就聚合 Tool 节点,按工具名分类和计数。AssistantNodeView 不查找工具块,Group 不编码工具 groupPart,也不接替工具生命周期。分组仍遵循[节点输入的分组决策](2026-09-21-conversation-build-groups.zh.md),Builder 不解释准备态业务。
|
||||
|
||||
### 通用行复用现有外壳
|
||||
|
||||
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) 在三个阶段复用既有图标、标题和行组件。公共行模型选择工具标题,并组合通用工具名前缀与已有参数摘要。准备阶段没有完整参数或结果,共享参数解析入口直接返回 null,不解析 JSON。既有 slot 钩子绑定可选地提供当前调用的原始前缀,不增加准备态注册机制;ToolRow 禁止准备行展开。
|
||||
|
||||
read、read_image、write/edit、search、web、todo、question、details 和通用回退使用这条路径。Bash、Skill、Present、Cordis 等自定义外壳保留自己的准备态分支。不增加自动/自管注册声明,不在 ToolTree 维护另一份工具分类名单。
|
||||
|
||||
### 与已有记录的关系
|
||||
|
||||
本记录替代[业务节点组装](2026-08-09-client-conversation-node-assembly.zh.md)中“start 只能是持久事件,同一 Context 只能收到一次 start”的限制。该记录的稳定 ID、业务 Definition、Context、Location、前序依赖和目标 Builder 职责继续适用。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**只在 `tool/call` 创建节点。** 无法展示参数生成期间已经确定的工具身份,多调用响应仍有明显空等。
|
||||
|
||||
**通过 Assistant 片段和 Group 转接。** 工具需要两种展示来源、额外去重和身份衔接;生命周期归属分散,渲染器还要根据片段标识反查 Assistant 内容。
|
||||
|
||||
**保存或回放 preparing。** 准备态只描述当前生成过程,持久调用和结果已经足以重建最终展示;恢复相同中间过程会增加不必要的状态和事件解释。
|
||||
|
||||
**新增准备态注册机制。** 通用工具已经共享 ToolRow 和参数模型,已有图标与标题可以直接复用。仅为这一阶段增加声明或配置,会多维护一套工具展示信息。
|
||||
|
||||
## 影响
|
||||
|
||||
- 工具可以在参数完整前出现,但准备展示不表示工具已经执行,也不暴露依赖参数的交互。
|
||||
- 同一 ID 的多个 start 被视为同一个生命周期;业务必须为独立调用提供不同 ID,不能再依赖第二个 start 报错检测身份复用。
|
||||
- 各阶段复用通用行基础组件。write/edit 分离准备态与派发后组件,只有前者订阅原始参数;自定义外壳也可按阶段切换内部组件。
|
||||
- 不提供部分 JSON 解析或 `useToolCallDelta`。`useToolCallArgumentsPartial` 暴露既有 Step 的原始前缀,不另建累积器;write/edit 只使用其长度展示准备进度。
|
||||
- 组装测试分别覆盖实时准备态、撤回、活跃流重建、持久历史与分页收敛;组件测试检查无参数展示和阶段切换,录制会话覆盖浏览器中的准备态与重载结果。
|
||||
+2
-2
@@ -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-31-fail-loud-releases-the-terminal.md
|
||||
2026-07-31-fail-loud-releases-the-terminal.md: a6b8ccc5a0ad885a349b32707face7bfce9d7bed
|
||||
2026-07-31-fail-loud-releases-the-terminal.zh.md: 728fca424d2622af6222d51a3240eafd0e117986
|
||||
2026-07-31-fail-loud-releases-the-terminal.md: 9c8aa41e960764c6d9311c49a8757a25b3996b78
|
||||
2026-07-31-fail-loud-releases-the-terminal.zh.md: e44d4672dcf768c33d94b801809cf7aa653aa62f
|
||||
@@ -29,6 +29,7 @@ The `/exit` path was never affected, because it disposes the tree and reaches th
|
||||
- A latch, not an uninstall, keeps the first rejection the reported one. Removing the listener during teardown would let a second concurrent rejection become uncaught, and Node would kill the process mid-teardown — stranding exactly the terminal state this restores. Later rejections, including the release's own, fall through to the pending exit.
|
||||
- The release is bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS` (2s) and its rejection is swallowed. A wedged or failing disposer delays the fatal exit; it never cancels it. That timer stays **referenced**: an `unref()`ed one lets Node reach an empty event loop and exit 0 on the very failure being reported, because an `unhandledRejection` listener suppresses the default fatal exit.
|
||||
- Omitting `release` keeps the previous behavior exactly, so the ACP, JSON-RPC, and demo bins are unchanged.
|
||||
- The same handler, latch, and release run for `uncaughtException` since the [fatal diagnostics and crash reports](../architecture/2026-09-22-fatal-diagnostics-and-crash-reports.md) decision, which also switched the diagnostic to `util.inspect`.
|
||||
|
||||
`dsh`'s TUI launcher passes a release that disposes the root context, which runs the TUI's existing `shutdown()` and hands the terminal back.
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动
|
||||
- 使用闩锁(latch)而非卸载监听器,来保证被报告的始终是第一个 rejection。若在拆卸期间移除监听器,第二个并发 rejection 就会变成未捕获错误,Node 会在拆卸中途杀死进程——恰好残留下本次要恢复的终端状态。后续 rejection(包括 release 自身的)都会落入已挂起的退出流程。
|
||||
- release 以 `FAIL_LOUD_RELEASE_TIMEOUT_MS`(2 秒)为上限,且其 rejection 被吞掉。卡住或失败的 disposer 只会延迟致命退出,绝不会取消它。该定时器保持 **referenced**:一旦 `unref()`,Node 就会在事件循环清空后、恰恰在报告这次失败时以 0 退出,因为 `unhandledRejection` 监听器抑制了默认的致命退出。
|
||||
- 不传 `release` 时行为与此前完全一致,因此 ACP(Agent Client Protocol)、JSON-RPC 和各 demo bin 均无变化。
|
||||
- 自[致命诊断与崩溃报告](../architecture/2026-09-22-fatal-diagnostics-and-crash-reports.zh.md)决定起,同一 handler、闩锁与 release 也对 `uncaughtException` 生效,诊断同时改为 `util.inspect`。
|
||||
|
||||
`dsh` 的 TUI 启动器传入的 release 会释放根上下文,从而执行 TUI 已有的 `shutdown()` 并把终端交还。
|
||||
|
||||
|
||||
@@ -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-17-durable-web-queue-recovery.md
|
||||
2026-08-17-durable-web-queue-recovery.md: 5fbfdf0e62c1034fbea138016cc2979489de474f
|
||||
2026-08-17-durable-web-queue-recovery.zh.md: 2aaffd6bc571aab449d9429758fdf45bb4dd8d4c
|
||||
2026-08-17-durable-web-queue-recovery.md: 0f18cd7a2b9491d6a8bdfa960409d97a5338f95d
|
||||
2026-08-17-durable-web-queue-recovery.zh.md: 476c1b2fe88156ba8c34e281ff1d1e5663c7913c
|
||||
@@ -49,3 +49,5 @@ Client coverage pins generic Inbox projection delivery, reconnect invalidation f
|
||||
Pending Queue and steering input recover after Host process restart without resuming an Agent. Live Inbox reads, cold history, reconnect, and projection caching use the same domain-owned fold and registry state. Operations on a restored row do resume its ordinary Agent, preserving preset composition and ownership checks.
|
||||
|
||||
Clients receive the raw two-list Inbox value and decide which messages their surface presents. The projection state version invalidates cached rows whenever its serialized state or fold semantics change.
|
||||
|
||||
The [input echo admission decision](2026-09-22-input-echo-admission-ownership.md) owns local Chat/Dock handoff and suppression of delayed Inbox rows without replacing this recovery model.
|
||||
@@ -49,3 +49,5 @@ Host 投影覆盖会读取包含待处理输入的已分离持久 Session,在
|
||||
待处理 Queue 与 steering 输入可在 Host 进程重启后恢复,而无需恢复 Agent。live Inbox 读取、冷历史、重连与投影缓存使用同一份领域拥有的折叠与注册表状态。操作恢复出的行时会恢复其普通 Agent,从而保留 preset 组合与所有权检查。
|
||||
|
||||
客户端接收原始的两列表 Inbox 值,并自行决定界面呈现哪些消息。投影的状态版本会在其序列化状态或折叠语义变化时使缓存行失效。
|
||||
|
||||
[输入回显入档决策](2026-09-22-input-echo-admission-ownership.zh.md)负责本地 Chat/Dock 接管和迟到 Inbox 行的排除,不替换这里的恢复模型。
|
||||
+6
@@ -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-09-21-multimodal-tool-result-retention.md
|
||||
2026-09-21-multimodal-tool-result-retention.md: 41470724911d3086878d206f82c76ad9a4993146
|
||||
2026-09-21-multimodal-tool-result-retention.zh.md: 8e6d4953f7380d45235ac13f9f245e2e005f1abf
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Multimodal tool-result retention
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-21-multimodal-tool-result-retention.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
MCP prepares durable images asynchronously while its pure renderer emits text placeholders. Installing prepared content after post-execute policy makes text retention invalidate image replacement. A byte-only limit also cannot compare text with visual input costs.
|
||||
|
||||
## Decision
|
||||
|
||||
`ToolDefinition.projectContent` installs execution-prepared content before post-execute policy. The final callback retains its separate role for terminal and job limits. Policy blocks and replacements remain authoritative.
|
||||
|
||||
The spill policy prices ordered text/image blocks in estimated tokens, reusing the text estimator and the active route image calculator, including descriptor text. It reserves omission notices, divides remaining tokens between contiguous ends, and omits the middle. Text can split between code points; images remain indivisible at their original positions.
|
||||
|
||||
The complete spill file contains accepted text and readable attachment paths in order. Attachments own image bytes. Missing image pricing or a readable recovery path preserves the original result with a diagnostic. PTC program values remain complete; forwarding and logs retain a recovery notice even when every image is omitted.
|
||||
|
||||
The [spill storage decision](../architecture/2026-07-08-tool-output-spill-files.md) still owns backend separation and secure local files. The [MCP client decision](../feature/2026-07-07-mcp-client-plugin.md) still owns canonical values and image admission.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Restore images after policy edits.** This can resurrect blocked images or discarded text. Preparing content before policy lets transformations inspect the actual images.
|
||||
|
||||
**Keep images outside the budget or charge a fixed maximum.** Images must share the result budget with text. The existing route calculator already prices projected dimensions and descriptor text, so it owns that cost.
|
||||
|
||||
**Embed image bytes in spill text.** Base64 inflates the artifact and duplicates attachment storage. Readable attachment paths preserve retrieval and ordering without copying bytes.
|
||||
|
||||
## Consequences
|
||||
|
||||
The shipped result budget is 12,500 estimated tokens, replacing the former 50,000-byte setting. Text estimation remains heuristic; provider usage is authoritative. Unused space at an image boundary stays unused to preserve contiguous ends. Other block types pass through.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 工具结果的图文保留
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-21-multimodal-tool-result-retention.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
MCP 异步准备持久化图片,而纯渲染器输出文字占位。在执行后策略完成后才安装已准备内容,会让文字保留使图片替换失效。按字节限制也无法比较文字与视觉输入的成本。
|
||||
|
||||
## Decision
|
||||
|
||||
`ToolDefinition.projectContent` 在执行后策略之前安装执行期间准备的内容。最终回调保留其对终端和任务结果设定上限的独立职责。策略阻止和替换仍然生效。
|
||||
|
||||
spill 策略以估算 token 对有序图文块计量,复用文字估算和当前模型图片计算器,并计入说明文字。它预留省略提示,将剩余 token 平分给连续两端,省略中间。文字可在码点之间切分;图片不可切分,并保持原位置。
|
||||
|
||||
完整结果文件按顺序保存已接受文字和可读取的附件路径。附件拥有图片字节。缺少图片计量或可读取的恢复路径时,保留原结果并记录诊断。PTC 程序返回值保持完整;全部图片被省略时,转发内容和日志仍保留读取提示。
|
||||
|
||||
[spill 存储决策](../architecture/2026-07-08-tool-output-spill-files.zh.md)继续负责后端拆分和安全的本地文件。[MCP 客户端决策](../feature/2026-07-07-mcp-client-plugin.zh.md)继续负责规范值和图片准入。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在策略修改后恢复图片。** 这可能恢复已阻止图片或已丢弃文字。在策略之前准备内容,让转换能够检查真实图片。
|
||||
|
||||
**图片不计入预算或按固定上限计费。** 图片必须与文字共享结果预算。现有模型计算器已经衡量请求尺寸和说明文字,因此由它负责成本。
|
||||
|
||||
**把图片字节嵌入 spill 文本。** Base64 会增大产物并重复附件存储。可读取的附件路径在不复制字节的情况下保留读取方式和顺序。
|
||||
|
||||
## Consequences
|
||||
|
||||
随产品提供的结果预算为 12,500 个估算 token,替代此前的 50,000 字节配置。文字计量仍为估算,提供方用量具有权威性。图片分界处剩余空间保持空闲,以维持连续的首尾内容。其他内容块类型原样通过。
|
||||
+6
@@ -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-09-21-persistent-pwsh-keeps-controlled-prompt.md
|
||||
2026-09-21-persistent-pwsh-keeps-controlled-prompt.md: 3fd42b77d4536215f34c32643526dbb03d21674f
|
||||
2026-09-21-persistent-pwsh-keeps-controlled-prompt.zh.md: 88d4e2deef26af4ad92ef4a69d8ae17403027f70
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Persistent pwsh keeps the backend's controlled prompt
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-21-persistent-pwsh-keeps-controlled-prompt.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-tool-pwsh-persistent` initialized its shell with a `prompt` function of its own (`'__DSH_PERSISTENT_PWSH_PROMPT__ '`), overwriting the `prompt` that `dsh-terminal-bash` installs in its pwsh startup sequence. The backend's prompt readiness requires the printable tail after the OSC `133;D` marker to exactly equal the controlled `dsh> ` prompt ([design](../feature/2026-07-16-persistent-pty-sessions.md)), so after initialization no send could settle through it and every send paid the silence tier plus handoff grace. Measured on Windows through the real Loader composition with production defaults: 8493 ms for the first call (spawn, initialization, and command) and 3722/3832/3759 ms for the next three, against 1340/255/251/241 ms with the controlled prompt intact. The package tests masked it by configuring `idleSilenceMs: 300`.
|
||||
|
||||
The [persistent bash tool fixed the identical defect](../../archived/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.md) by giving up its own prompt override; the pwsh twin kept it. The override served the same two consumers there: a viewport-suffix fallback detecting "shell at a prompt without the end marker", and cosmetic stripping of prompt text from partial output.
|
||||
|
||||
## Decision
|
||||
|
||||
The tool no longer installs a prompt. `dsh-terminal-bash` owns the pwsh `prompt` function, installs it during `spawn`, and returns the session only after that startup reached `stdin_read` readiness, so the tool's initialization send had nothing left to establish and is deleted together with its `PWSH_PROMPT_SETUP` command.
|
||||
|
||||
The viewport-suffix fallback is replaced with the seam's own signal: a send that settles as `stdin_read` without the end marker in scrollback returns the captured partial output. The private prompt constant and its stripping are deleted; partial output may now end with the backend's own prompt text, which the tool cannot and should not know. `stripPrompt` collapses to `trimTrailingNewline`, matching `dsh-tool-bash-persistent`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Align the tool's prompt text with the backend's `dsh> `.** Rejected: it keeps a second installation of one protocol constant and a second owner of it, and the send is redundant because the backend had already installed the identical prompt before `spawn` resolved.
|
||||
|
||||
**Import the controlled prompt into the tool.** Rejected: the prompt is one provider's protocol constant; a Consumer matching it would couple the tool to `dsh-terminal-bash` specifically, and any other mounted pwsh-dialect backend would break it again.
|
||||
|
||||
**Lower `handoffGraceMs`/`idleSilenceMs` instead.** Rejected: no silence value fixes a dead fast path; it rebalances how much every call overpays, and the silence tier is also what absorbs a genuinely broken prompt.
|
||||
|
||||
**Re-assert the pwsh prompt per prompt, as `PROMPT_COMMAND` does for bash.** Rejected: PowerShell has no per-prompt hook outside the `prompt` function itself, so a redefinition cannot be healed the way bash's variable is; the residue stays a documented limitation.
|
||||
|
||||
## Consequences
|
||||
|
||||
Windows, production defaults, real Loader composition: tool calls drop from 8493/3722/3832/3759 ms to 1340/255/251/241 ms (spawn + initialization + first command, then three warm commands).
|
||||
|
||||
The `stdin_read` fallback is behavior, not only cosmetics: where a provider proves a foreground stdin wait, an interactive child returns captured partial output instead of spinning to the command deadline, and elsewhere the call still runs to `timeoutMs`. That early return keeps the session, so a child still reading stdin can consume the next call's command until that call reaches `timeoutMs` and resets the shell; `dsh-tool-bash-persistent` behaves the same way, and distinguishing the shell's own prompt readiness from a child's stdin wait belongs to the seam rather than to either tool. Complete marker-delimited output is unchanged.
|
||||
|
||||
The loader-composition suite records every send's `waitReason` and requires at least six `stdin_read` settlements and no `inferred_idle`: the regression is reported by the settle reason itself, not by how long silence takes. Reinstating the removed prompt override fails that assertion after 30 s with `expected 0 to be greater than or equal to 6`, while the case's output assertions still pass. The stub suite drops the initialization-handshake modes and pins the prompt text retained in partial output instead of its absence.
|
||||
|
||||
A model command that redefines `prompt` still removes the readiness marker and degrades later sends to the silence tier: the backend installs its prompt once at startup and cannot re-assert it.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Agent Note: 持久 pwsh 保留后端的受控提示符
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-21-persistent-pwsh-keeps-controlled-prompt.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-tool-pwsh-persistent` 用自己安装的 `prompt` 函数(`'__DSH_PERSISTENT_PWSH_PROMPT__ '`)初始化其 shell,覆盖了 `dsh-terminal-bash` 在 pwsh 启动序列中安装的 `prompt`。后端的提示符就绪检测要求 OSC `133;D` 标记之后的可打印尾部与受控提示符 `dsh> ` 完全相等([设计](../feature/2026-07-16-persistent-pty-sessions.zh.md)),因此初始化之后任何 send 都无法经由该路径结算,每次 send 都要支付静默层加交接宽限。在 Windows 上通过真实 Loader 组合以生产默认值实测:首次调用(spawn、初始化与命令)8493 毫秒,随后三次为 3722/3832/3759 毫秒;受控提示符完好时为 1340/255/251/241 毫秒。包测试把 `idleSilenceMs` 配成 300,掩盖了该问题。
|
||||
|
||||
[持久 bash 工具已修复同一缺陷](../../archived/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.md),方式是放弃自己的提示符覆盖;pwsh 孪生工具保留了它。该覆盖在两个工具中服务同样的两个消费点:用视口后缀检测「shell 已回到提示符但没有结束标记」的回退判定,以及从部分输出中剥离提示符文本的美化。
|
||||
|
||||
## Decision
|
||||
|
||||
工具不再安装提示符。pwsh 的 `prompt` 函数由 `dsh-terminal-bash` 拥有,在 `spawn` 期间安装,且只在该启动流程达到 `stdin_read` 就绪后才返回会话,因此工具的初始化 send 已无可确立之事,与其 `PWSH_PROMPT_SETUP` 命令一并删除。
|
||||
|
||||
视口后缀回退替换为 seam 自己的信号:一次以 `stdin_read` 结算而 scrollback 中没有结束标记的 send,返回已捕获的部分输出。私有提示符常量及其剥离逻辑删除;部分输出现在可能以后端自己的提示符文本结尾,工具无法也不应知道该文本。`stripPrompt` 收敛为 `trimTrailingNewline`,与 `dsh-tool-bash-persistent` 一致。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**把工具的提示符文本对齐到后端的 `dsh> `。** 被拒绝:这保留了同一个协议常量的第二份安装与第二个拥有者,而且该 send 是多余的——后端在 `spawn` 返回前就已安装相同的提示符。
|
||||
|
||||
**把受控提示符导入工具。** 被拒绝:提示符是单个提供方的协议常量;Consumer 匹配它就把工具与 `dsh-terminal-bash` 具体耦合,换任何其他 pwsh 方言后端都会再次损坏。
|
||||
|
||||
**改为调小 `handoffGraceMs`/`idleSilenceMs`。** 被拒绝:任何静默值都修不好已死的快速路径,只是重新分配每次调用多付多少;而静默层同时也是吸收真正损坏提示符的那一层。
|
||||
|
||||
**像 bash 的 `PROMPT_COMMAND` 那样在每个提示符处重新设定 pwsh 提示符。** 被拒绝:除 `prompt` 函数本身之外,PowerShell 没有每提示符钩子,因此重定义无法像 bash 的变量那样被治好;该残余已记入已知限制。
|
||||
|
||||
## Consequences
|
||||
|
||||
Windows、生产默认值、真实 Loader 组合:工具调用从 8493/3722/3832/3759 毫秒降至 1340/255/251/241 毫秒(spawn + 初始化 + 首条命令,随后三条热命令)。
|
||||
|
||||
`stdin_read` 回退是行为而不只是美化:在提供方能证明前台 stdin 等待之处,交互式子进程会返回已捕获的部分输出,而不是空转到命令期限;其他情况下调用仍会运行到 `timeoutMs`。该提前返回保留会话,因此仍在读取 stdin 的子进程可能吞掉下一次调用的命令,直到那次调用到达 `timeoutMs` 并重置 shell;`dsh-tool-bash-persistent` 行为相同,而区分「shell 自身的提示符就绪」与「子进程的 stdin 等待」属于 seam,而不是任一工具。由标记界定的完整输出保持不变。
|
||||
|
||||
loader 组合套件记录每次 send 的 `waitReason`,要求至少六次 `stdin_read` 且没有 `inferred_idle`:回归由结算原因本身报告,而不是靠静默层耗时。把删掉的提示符覆盖加回去,该断言会在 30 秒后以 `expected 0 to be greater than or equal to 6` 失败,而同一用例的输出断言仍然通过。stub 套件删除了初始化握手模式,并改为锚定部分输出中保留的提示符文本,而不是它的缺席。
|
||||
|
||||
重定义 `prompt` 的模型命令仍会移除就绪标记,并把后续 send 降级到静默层:后端只在启动时安装一次提示符,无法重新设定。
|
||||
+6
@@ -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-09-22-chat-scroll-follow-and-footer-geometry.md
|
||||
2026-09-22-chat-scroll-follow-and-footer-geometry.md: 56cd1681eef23162df2595a138545ade25e34212
|
||||
2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md: 9979688d52373ae7a64a3f4fc22b932b43091b34
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# Agent Note: Separate Chat clipping and independent follow intent
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Transcript overflow must remain clipped without trapping floating controls or changing the scroll ancestor of sticky content. An `overflow-y: hidden` ancestor becomes the nearest scroll container even when readers scroll a different outer element. It prevents Markdown code banners and expanded compaction headers from sticking to the visible conversation scrollport; floating controls inside that ancestor also use transcript geometry instead of viewport geometry.
|
||||
|
||||
A height-capped process group also has a separate reading position. Outer bottom-follow cannot expose newly appended content inside a group whose own scroll offset stays unchanged. Treating every non-bottom scroll event as reader input does not support native smooth following: the browser emits those same events while an animation approaches its target.
|
||||
|
||||
## Decision
|
||||
|
||||
The transcript, floating controls, and scroll intent retain separate ownership. The [scroll-state table](../../../../packages/client/ui-chat/README.md#scroll-ownership) and [group display rules](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.md#display-modes) define the current product behavior.
|
||||
|
||||
| Area | Implementation | Reason |
|
||||
|---|---|---|
|
||||
| Transcript clipping | `ChatView` uses `overflow-x: visible; overflow-y: clip` on `.root` inside an unclipped frame. | Constrain vertical overflow without introducing a scroll container between sticky content and its actual scrollport. |
|
||||
| Floating controls | TurnNavigator and the back-to-bottom button are siblings of the clipped transcript. | Shared Conversations use the outer scrollport for sticky positioning; standalone Chat uses its frame for absolute positioning. |
|
||||
| Follow intent | `useScrollFollow` retains one independent controller per scrollport. | Reuse thresholds, position sampling, and native scrolling without sharing one follow flag. |
|
||||
| Group lifecycle | `useProcessScroll` owns its existing body/content observer, directional fades, and one-shot opening position. | Group growth remains observable after the body reaches its cap; opening performs no immediate geometry read. |
|
||||
|
||||
The clipped root owns no scroll offset. Shared Conversation scrolling, standalone Chat scrolling, capped process groups, and terminal sections retain their existing scrollports. The horizontal axis stays `visible`: pairing vertical `clip` with horizontal `auto` or `hidden` computes the vertical overflow back to `hidden` and restores the unwanted scroll container.
|
||||
|
||||
An unclosed group opens at its bottom; a closed group opens at its top. The group’s `closed` value, not the whole Turn’s status or an all-node search, decides that initial position. A later close does not reset an already-open reader. Browser find keeps its own reveal position, and display-mode changes preserve mounted members.
|
||||
|
||||
Native smooth growth keeps an outstanding target separate from the current position. Animation progress retains follow intent. Input interrupts native motion before the browser applies reader movement; the resulting actual displacement determines whether following stops or resumes. Further growth retains the issued target until `scrollend`. Arrival at that target, including shrink clamping, allows one request to the latest floor; an off-target stop releases following so focus and find keep their landing. Reduced motion and explicit opening positions use immediate scrolling. Outer following keeps its existing immediate behavior and sampling policy.
|
||||
|
||||
The back-to-bottom button depends only on outer follow intent and restores only that intent. Inner scrolling affects the outer controller only when scroll chaining actually moves the outer scrollport. Group height growth can resize the outer transcript, but the controllers do not invoke each other’s follow actions. Paging and semantic restoration remain with the existing outer reading/navigation owners.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Remove transcript clipping.** That would abandon the overflow constraint. Vertical `clip` retains clipping without creating a scroll container; the unclipped sibling frame keeps floating controls outside the clipped area without a Portal or document-level coordinate synchronization.
|
||||
|
||||
**Keep `overflow-y: hidden` and move only floating controls.** That protects the controls but leaves sticky content inside the unwanted scroll container. Changing the root to `clip` also preserves the scroll ancestor of code and compaction headers without moving those headers out of their content.
|
||||
|
||||
**Share one follow flag, or duplicate the complete controllers.** One flag would pull a group away from a reader merely because the outer transcript follows. Two complete implementations would duplicate animation attribution and threshold rules. Sharing the small controller retains independent state without moving paging, Turn navigation, or semantic memory into it.
|
||||
|
||||
**Only replace `scrollTop` assignment with smooth `scrollTo`.** Intermediate animation offsets would appear to be reader movement and disable following. Native motion needs an explicit target and input interruption; a custom animation-frame interpolator is unnecessary.
|
||||
|
||||
## Verification
|
||||
|
||||
[Controller tests](../../../../packages/client/ui-chat/tests/scroll-follow.client.spec.ts) cover independent thresholds, animation progress, target reuse, input interruption, reduced motion, and immediate positioning. [Chat component tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover group opening, pause/resume, closing while open, and browser-find preservation. [Viewport tests](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts) retain the outer anchor and attribution behavior.
|
||||
|
||||
[Browser scroll cases](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) exercise floating-control geometry, nested following, paging, streaming, and real wheel input through the shipped Web composition.
|
||||
|
||||
[Seeded-history cases](../../../../apps/web/tests/seeded-history.e2e.ts) check an expanded compaction header and its nested code banner against the actual conversation scrollport, including their stacking and copy-button access.
|
||||
|
||||
## Consequences
|
||||
|
||||
The shared controller stores intent without scheduling React renders; callers still publish visible state changes, and group edge fades update only when their booleans change. Follow operations read the affected scrollport, not its Node collection. Native scrolling still performs browser layout and paint work. These changes establish behavior and ownership, not a measured latency or memory improvement.
|
||||
|
||||
The [navigation decision](../architecture/2026-09-18-chat-navigation-performance.md) still owns sampled reading, known landings, and batched historical jumps. The [grouping decision](../architecture/2026-09-21-conversation-build-groups.md) still owns Definition-based membership and stable rendering identities. Extracting scroll-follow mechanics does not supersede either decision.
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# Agent Note: 分离 Chat 裁剪与独立滚动跟随意图
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-22-chat-scroll-follow-and-footer-geometry.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
正文溢出必须保持裁剪,同时不能限制悬浮控件或改变 sticky 内容的滚动祖先。即使读者实际滚动的是外层元素,`overflow-y: hidden` 祖先也会成为最近的滚动容器。这会阻止 Markdown 代码块头栏和展开的压缩摘要头栏吸附在可见会话滚动区域;位于该祖先内的悬浮控件也会参照正文几何,而非视口几何。
|
||||
|
||||
限高过程组也有独立阅读位置。若组内偏移不变,外层跟随底部无法展示组内新增内容。把每个未到底的滚动事件都当成读者输入,也无法支持原生平滑跟随:浏览器在动画接近目标期间同样会发出这些事件。
|
||||
|
||||
## 决策
|
||||
|
||||
正文、悬浮控件及滚动意图分别由独立对象负责。[滚动状态表](../../../../packages/client/ui-chat/README.zh.md#scroll-ownership)与[分组展示规则](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.zh.md#display-modes)定义当前产品行为。
|
||||
|
||||
| 范围 | 实现 | 原因 |
|
||||
|---|---|---|
|
||||
| 正文裁剪 | `ChatView` 在无裁剪的外框内,对 `.root` 使用 `overflow-x: visible; overflow-y: clip`。 | 限制纵向溢出,不在 sticky 内容与其实际滚动区域之间引入滚动容器。 |
|
||||
| 悬浮控件 | TurnNavigator 与回到底部按钮位于正文裁剪层的同级。 | 共享 Conversation 相对外层滚动容器做 sticky 定位;独立 Chat 相对自身外框做 absolute 定位。 |
|
||||
| 跟随意图 | `useScrollFollow` 为每个滚动区保留独立控制器。 | 共用阈值、位置采样及原生滚动,不共用一个跟随开关。 |
|
||||
| 小组生命周期 | `useProcessScroll` 负责既有的正文与内容观察器、方向渐隐和一次性展开位置。 | 正文达到限高后,内容增长仍可被观察;展开时不立即读取几何。 |
|
||||
|
||||
被裁剪的根节点不持有滚动偏移。共享 Conversation、独立 Chat、限高过程组和终端区域保留各自原有的滚动容器。横轴保持 `visible`:纵向 `clip` 如果与横向 `auto` 或 `hidden` 组合,纵向溢出的计算值会变回 `hidden`,重新引入不需要的滚动容器。
|
||||
|
||||
未关闭的小组展开到自己的底部,已关闭的小组展开到顶部。初始位置由组自身的 `closed` 决定,不取整个 Turn 的状态,也不遍历全部节点。组随后关闭不会重置已展开的阅读位置。浏览器查找保留自身的展开定位,模式切换保留已挂载成员。
|
||||
|
||||
原生平滑跟随将未完成的目标与当前位置分开记录。动画进度保留跟随意图。输入在浏览器处理读者移动前中断原生动画,再由实际位移决定是否暂停或恢复跟随。后续增长保留已发出的目标,直到 `scrollend`。到达该目标(包括内容缩短后的钳制位置)才允许再次请求最新底部;停在目标之外则释放跟随,让焦点和查找保留落点。减少动态效果与明确的展开定位使用即时滚动。外层跟随保留既有的即时行为和采样策略。
|
||||
|
||||
回到底部按钮只取决于外层跟随意图,也只恢复该意图。只有滚动传递确实移动外层滚动区域时,内层滚动才影响外层控制器。小组长高可以改变外层正文尺寸,但两个控制器不会互相调用跟随操作。分页和语义恢复仍由既有的外层阅读与导航对象负责。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**删除正文裁剪。** 这会放弃溢出约束。纵向 `clip` 保留裁剪而不创建滚动容器;无裁剪的同级外框让悬浮控件位于裁剪区域之外,不需要 Portal 或文档级坐标同步。
|
||||
|
||||
**保留 `overflow-y: hidden`,仅移动悬浮控件。** 这可以保护控件,但 sticky 内容仍位于不需要的滚动容器内。将根节点改为 `clip` 也能保留代码和压缩摘要头栏的滚动祖先,无需把这些头栏移出其正文。
|
||||
|
||||
**共用一个跟随开关,或复制完整控制器。** 共用开关会仅因外层跟随就把小组拉离读者位置。复制两套完整实现则会重复动画归因和阈值规则。共用小控制器可以保留独立状态,同时不把分页、轮次导航及语义记忆搬入其中。
|
||||
|
||||
**只把 `scrollTop` 赋值换成平滑 `scrollTo`。** 动画中间偏移会被误判为读者移动,导致跟随关闭。原生动画需要明确的目标与输入中断处理,无需自写动画帧插值器。
|
||||
|
||||
## 验证
|
||||
|
||||
[控制器测试](../../../../packages/client/ui-chat/tests/scroll-follow.client.spec.ts)覆盖独立阈值、动画进度、目标复用、输入中断、减少动态效果和即时定位。[Chat 组件测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx)覆盖小组展开、暂停与恢复、展开期间关闭及浏览器查找位置保持。[视口测试](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts)保留外层锚点与归因行为。
|
||||
|
||||
[浏览器滚动用例](../../../../apps/web/tests/chat-scroll-contract.e2e.ts)通过正式 Web 装配验证悬浮控件几何、嵌套跟随、分页、流式内容与真实滚轮输入。
|
||||
|
||||
[历史回放用例](../../../../apps/web/tests/seeded-history.e2e.ts)相对实际会话滚动区域检查展开的压缩摘要头栏及其内部代码头栏,包括层叠关系和复制按钮的可访问性。
|
||||
|
||||
## 影响
|
||||
|
||||
共享控制器只保存意图,不自行调度 React 渲染;调用方仍发布可见状态变化,小组边缘渐隐仅在布尔值变化时更新。跟随操作读取受影响的滚动容器,而非节点集合。原生滚动仍有浏览器布局和绘制成本。这些改动确立行为与职责,不代表已测得延迟或内存收益。
|
||||
|
||||
[导航决策](../architecture/2026-09-18-chat-navigation-performance.zh.md)继续负责采样阅读、已知落点与聚合历史跳转。[分组决策](../architecture/2026-09-21-conversation-build-groups.zh.md)继续负责 Definition 成员关系与稳定渲染身份。提取滚动跟随机制不取代这两项决策。
|
||||
@@ -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-09-22-input-echo-admission-ownership.md
|
||||
2026-09-22-input-echo-admission-ownership.md: 227857bd8168aa1d7f51b621737c2ed0ab572632
|
||||
2026-09-22-input-echo-admission-ownership.zh.md: 1d64de220a3c6d5c9357770175c79266b8f9f063
|
||||
@@ -0,0 +1,54 @@
|
||||
# Agent Note: Input echoes retain display ownership through admission
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-22-input-echo-admission-ownership.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
An idle submission can appear first as a local Chat echo, then as an Inbox row in QueueDock, and finally as a durable user message. Inbox projection and conversation history arrive independently: retiring the echo on acceptance leaves a gap before admission, while a delayed Inbox projection can duplicate an already-admitted message. Separately, turn/start precedes user/message, so an empty progress title can appear before the echo and exchange positions with it when the real input arrives.
|
||||
|
||||
These transitions concern display ownership, not scrolling or composer geometry. A delay cannot identify which representation is authoritative, and moving every echo before the progress title would move running steering ahead of existing work.
|
||||
|
||||
## Decision
|
||||
|
||||
- A submission keeps its locally selected placement: idle input stays in Chat, running steering stays at the process tail, and explicitly queued input stays in QueueDock. Inbox acceptance does not change that placement.
|
||||
- Chat hides an echo by matching the durable input's rpcId in the same render, and excludes stale next-step Inbox rows matched by admitted local Chat submissions. QueueDock excludes only Inbox rows matched by local transcript echoes; unrelated queued messages remain visible and actionable.
|
||||
- Session retains an admitted transcript or steering submission's local suppression identity until the Inbox projection watermark reaches its claim sequence. Higher-sequence projection retention then prevents an older accepted row from reappearing after that identity retires. Claim tracking and cancellation use the existing per-submission receipt for both Inbox targets; no new SessionSnapshot field, Host event, or persisted format is needed.
|
||||
- At an open, input-free progress control at the end of Chat, the first transcript echo precedes the control. Other echoes remain at the tail. Last-input Turn tracking shares the existing rpcId scan; it does not add a history traversal or predict a sequence of future Turns.
|
||||
- Pending bubbles and durable seats share one keyed React list. Inserting a progress title preserves the pending bubble's mounted identity. Once admitted, the durable node owns ordering and ordinary Group segmentation.
|
||||
|
||||
The [durable Inbox recovery decision](2026-08-17-durable-web-queue-recovery.md) still owns cold reads, reconstruction and projection transport. The [generic file upload decision](../feature/2026-08-26-generic-file-upload.md) still owns attachment preparation and draft recovery; retirement returns each submission's durable references once, in attachment order.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Delay QueueDock or echo removal by a fixed interval.** CPU throttling, transport batching and projection ordering can exceed any such interval. Time does not establish admission or confirm that stale queue state is gone.
|
||||
|
||||
**Retain the echo without changing the other readers.** Chat-side Inbox deduplication can still remove the echo, and QueueDock can still display it. The Session lifetime, Chat handoff and Dock exclusion must agree.
|
||||
|
||||
**Delete the suppression identity as soon as user/message arrives.** A later Inbox acceptance projection can resurrect a Dock or pending-steering row. The claim watermark supplies the required ordering evidence without assuming the streams publish together.
|
||||
|
||||
**Move all pending inputs before the newest Turn control.** Running steering belongs after existing process content, and several ordinary submissions can target different Turns. Only the first local transcript echo receives the empty-control placement adjustment.
|
||||
|
||||
**Add a Host correlation event or a client-side Turn scheduler.** Exact prediction across competing clients and reordered requests would widen the change beyond local optimistic presentation. Durable admission remains authoritative; optimistic ordering is deliberately approximate in those races.
|
||||
|
||||
## Verification
|
||||
|
||||
| Evidence | Covered behavior |
|
||||
|---|---|
|
||||
| [Session submissions](../../../../packages/api/session-controller/tests/session-pending-submissions.client.spec.ts) | Projection-first and history-first handoff, claim watermark, overlapping opening/queue/steering identities, attachment callbacks, cancellation, unadmitted endings, reconnect baselines and disposal. |
|
||||
| [Projection store](../../../../packages/api/session-controller/tests/projection-store.client.spec.ts) | Missing/cached watermarks, monotonic sequence retention and clearing. |
|
||||
| [Chat rendering](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) | Same-render rpcId replacement, stable echo mounting, title-before-input delivery in all three modes, steering remaining behind the title, historical empty Turns, and six local-versus-Host orderings. |
|
||||
| [QueueDock](../../../../packages/client/ui-conversation/tests/queue-dock.client.spec.tsx) | Local transcript exclusion, unrelated rows/actions, and all six three-message acceptance orders followed by FIFO claims. |
|
||||
| [Recorded Web replay](../../../../apps/web/tests/idle-submission-handoff.e2e.ts) | Real Web composition with FIFO-preserving delivery barriers: both Inbox/history arrival orders at 6× CPU throttling, an explicit turn/start-to-user/message gap, and complete three-Turn handoff for all six request orders. |
|
||||
| [Steering Web replay](../../../../apps/web/tests/steering.e2e.ts) | Local shortcut steering remains unique when durable admission precedes its Inbox claim projection. |
|
||||
|
||||
The single-input regression fails before the fix: Inbox acceptance moves the bubble into Dock, and a separately delivered Turn title precedes the echo. The corrected path retains one Chat representation at a stable vertical position. Multi-input replay verifies durable order and absence of duplicate or residual echoes; it does not assert that optimistic ordering predicts Host order.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Steering without a locally tracked submission still follows the Inbox projection; a delayed projection can briefly duplicate its durable row. Local deduplication adds no history scan for other clients' submissions.
|
||||
- Reconnect may discard receipt-confirmed optimistic echoes and display the new authoritative baseline. A claimed input outside that baseline can briefly have no bubble; reconnect does not promise DOM continuity.
|
||||
- Submissions made before the local running update can all remain transcript echoes. If A, B and C are submitted locally in that order but B or C enters first, durable admission changes their visible order. Even when A enters first, remaining local echoes can differ from the Host queue order.
|
||||
- Cross-client competition and request-preparation races can change the actual opening input. The client does not migrate an idle-classified echo into Dock merely because the Host queued it behind another Turn.
|
||||
- The changes prevent representation handoff flicker on the ordinary online path without changing model input, Inbox scheduling, scroll-follow policy, or released Session data. The browser barriers establish behavior, not a general latency or throughput improvement.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Agent Note: 输入回显在入档期间保持展示归属
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-22-input-echo-admission-ownership.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
空闲时提交的输入可能先显示为 Chat 本地回显,再变为 QueueDock 的 Inbox 行,最后才显示为持久用户消息。Inbox 投影与会话历史独立到达:受理时回收回显会在入档前留下空档,迟到的 Inbox 投影又可能重复显示已经入档的消息。此外,turn/start 先于 user/message,因此空过程标题可能先出现在回显前面,真实输入到达时再与它交换位置。
|
||||
|
||||
这些转换涉及展示归属,而非滚动或 composer 几何。延迟不能判断哪种表示已经成为权威,将所有回显移到过程标题前又会把运行中的 steering 移到已有工作之前。
|
||||
|
||||
## 决策
|
||||
|
||||
- 提交保留本地发送时选择的 placement:空闲输入留在 Chat,运行中的 steering 留在过程末尾,明确排队的输入留在 QueueDock。Inbox 受理不改变展示归属。
|
||||
- Chat 在同一次渲染中按持久输入的 rpcId 隐藏回显,并排除匹配已入档本地 Chat 提交的旧 next-step Inbox 行。QueueDock 只排除匹配本地 transcript 回显的 Inbox 行;其他排队消息仍可见且可操作。
|
||||
- Session 保留已入档 transcript 或 steering 提交的本地排除身份,直到 Inbox 投影水位达到其领取序号。随后,较高序号投影优先的规则阻止旧受理行在该身份回收后重新出现。领取跟踪和取消共用已有逐提交 receipt,并覆盖两种 Inbox target;不新增 SessionSnapshot 字段、Host 事件或持久格式。
|
||||
- Chat 末尾为进行中且没有真实输入的过程控制行时,第一条 transcript 回显显示在控制行前,其他回显留在末尾。最后输入的轮次信息复用现有 rpcId 扫描,不新增历史遍历,也不预测未来轮次序列。
|
||||
- 待处理气泡与持久节点容器共用一个 keyed React 列表。插入过程标题保留回显气泡的挂载身份。入档后,由持久节点决定排序及普通 Group 切分。
|
||||
|
||||
[持久 Inbox 恢复决策](2026-08-17-durable-web-queue-recovery.zh.md)仍负责冷读取、重建与投影传输。[通用文件上传决策](../feature/2026-08-26-generic-file-upload.zh.md)仍负责附件准备及草稿恢复;回收按附件顺序返回各提交的持久引用,且只回调一次。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**固定延迟 QueueDock 或回显回收。** CPU 降速、传输批处理与投影到达顺序都可能超过该间隔。时间不能证明入档,也不能确认过期队列状态已经消失。
|
||||
|
||||
**只延长回显生命周期,不调整其他读取方。** Chat 侧按 Inbox 去重仍会移除回显,QueueDock 仍可能展示同一消息。Session 生命周期、Chat 接管和 Dock 排除必须一致。
|
||||
|
||||
**user/message 到达后立即删除排除身份。** 较晚的 Inbox 受理投影会复活 Dock 行或待处理 steering 行。领取水位提供必要的排序依据,不要求两条流同时发布。
|
||||
|
||||
**把全部待处理输入移到最新 Turn 控制行前。** 运行中的 steering 属于已有过程内容之后,多条普通提交也可能属于不同轮次。只有第一条本地 transcript 回显参与空控制行前的定位调整。
|
||||
|
||||
**新增 Host 关联事件或客户端 Turn 调度器。** 精确预测跨客户端竞争和请求乱序会超出本地乐观展示的范围。持久入档仍是权威;这些竞争场景中的乐观排序有意保持近似。
|
||||
|
||||
## 验证
|
||||
|
||||
| 证据 | 覆盖行为 |
|
||||
|---|---|
|
||||
| [Session 提交](../../../../packages/api/session-controller/tests/session-pending-submissions.client.spec.ts) | 投影先到与历史先到的接管、领取水位、开场/排队/steering 身份交错、附件回调、取消、未入档结束、重连基线及销毁。 |
|
||||
| [投影存储](../../../../packages/api/session-controller/tests/projection-store.client.spec.ts) | 缺失或缓存水位、单调序号保留及清理。 |
|
||||
| [Chat 渲染](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) | 同帧 rpcId 替换、回显稳定挂载、三种模式下标题先于输入到达、steering 留在标题之后、历史空轮次及六种本地与 Host 排列。 |
|
||||
| [QueueDock](../../../../packages/client/ui-conversation/tests/queue-dock.client.spec.tsx) | 本地 transcript 排除、其他行及操作、三条消息的全部六种受理顺序及后续 FIFO 领取。 |
|
||||
| [录制 Web 回放](../../../../apps/web/tests/idle-submission-handoff.e2e.ts) | 真实 Web 装配配合保留 FIFO 的投递屏障:6× CPU 降速下的 Inbox/历史两种到达顺序、明确的 turn/start 至 user/message 空档,以及六种请求排列的完整三轮接管。 |
|
||||
| [Steering Web 回放](../../../../apps/web/tests/steering.e2e.ts) | 本地快捷键 steering 的持久入档先于 Inbox 领取投影到达时,仍只显示一份。 |
|
||||
|
||||
单条输入回归在修复前失败:Inbox 受理将气泡移入 Dock,单独交付的 Turn 标题又会出现在回显之前。修复后的路径保留唯一的 Chat 表示,且纵向位置稳定。多输入回放验证持久顺序及无重复、无残留回显,不断言乐观排列可以预测 Host 顺序。
|
||||
|
||||
## 后果
|
||||
|
||||
- 没有本地提交身份的 steering 仍按 Inbox 投影显示;投影迟到时可能短暂与持久节点重复。本地去重不为其他客户端的提交增加历史扫描。
|
||||
- 重连可撤销已有接收回执的乐观回显,并展示新的权威基线。已领取但不在该基线中的输入可能短暂无气泡;重连不保证 DOM 连续性。
|
||||
- 本地 running 更新前连续提交的消息可能都留为 transcript 回显。若本地按 A、B、C 提交,但 B 或 C 先入档,持久接管会调整可见顺序。即使 A 先入档,其余本地回显也可能与 Host 队列顺序不同。
|
||||
- 跨客户端竞争和请求准备的先后差异可能改变实际开场输入。客户端不会仅因 Host 将输入排在另一轮后面,就把已按空闲分类的回显迁入 Dock。
|
||||
- 这些改动消除普通在线路径中表示切换带来的抖动,不改变模型输入、Inbox 调度、滚动跟随规则或已发布 Session 数据。浏览器屏障证明行为,不代表普遍的延迟或吞吐提升。
|
||||
+6
@@ -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-09-22-windows-open-through-shell-resolution.md
|
||||
2026-09-22-windows-open-through-shell-resolution.md: 7bfe559e7f26b96fa37d3bbed44f0403d4e51b59
|
||||
2026-09-22-windows-open-through-shell-resolution.zh.md: ff33455da1653f9d24f9ddcc21c1594e8875bb5e
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note: Windows opens go through explorer.exe
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-22-windows-open-through-shell-resolution.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Windows desktop client's settings sheet offers **Open configuration file**, which prepares the active profile's `cordis.patch.yml` and hands the path to the native text-document opener (issue #4428). On the reporting machine the gesture opened nothing and reported nothing. The opener ran `powershell.exe -NoProfile -Command "Invoke-Item -LiteralPath '<path>'"`, so an association resolved inside the host process decided the outcome, and `Invoke-Item` exits 0 when that resolution finds no application.
|
||||
|
||||
That machine records the `.yml` default only in `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.yml\UserChoiceLatest` (progid `VSCode.yml`), with no `UserChoice` record and no `HKCU\Software\Classes\.yml` default. Explorer opens VS Code on a double-click of the same file. Run from the host process, `Invoke-Item`, `cmd /c start`, the `Shell.Application` default verb, and `AssocQueryString` all reported no application for that extension, and `cmd /c start` displayed the shell's own application chooser instead of opening one. `Invoke-Item` still exited 0, so `SettingsController.openSettingsDocument` answered `{ opened: true }` and the sheet rendered no error.
|
||||
|
||||
## Decision
|
||||
|
||||
`openWindowsPath` hands the path to `explorer.exe` as one argv element, and `revealNativePath` reaches the same shell through the shared `runExplorer` helper. Both encode the target as a file URI with its commas and equals signs percent-encoded, because Explorer parses its own command line and splits fields at both — a raw path containing either opens a different target without reporting it. Nothing else is escaped: Explorer rejects percent-encoded non-ASCII in a file URI and falls back to Documents, so those characters reach it literally. Explorer performs the default-application resolution a double-click performs, so the shell's answer — including a default recorded only in the newer per-user record — selects the application, and a machine with no handler at all gets Explorer's own "How do you want to open this file?" chooser instead of silence.
|
||||
|
||||
`runExplorer` holds the one Explorer invocation rule for both operations: exit code 1 is the handoff Explorer returns after passing the request to the desktop process already running, and it resolves the caller's promise; every other failure and every cancellation still rejects. The helper replaced the PowerShell command string, and with it `powershellLiteral` and its single-quote doubling: a path crosses the process boundary as an argv element, which removes the shell-layer escaping rather than the encoding Explorer's own parser requires. `explorerTarget` owns that one encoding step for both intents.
|
||||
|
||||
Windows still names no browser for HTML and SVG, so `openInBrowser` continues to decline the platform and the default, association, and text-editor intents all reach the same Explorer handoff.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep `Invoke-Item`.** Rejected. It asks a different resolver than the shell does, so a user whose default Explorer honors can still get a silent no-op, and its zero exit code removes the error the sheet would otherwise render.
|
||||
|
||||
**Resolve the default inside the host process.** Rejected. Reading `UserChoiceLatest`, then `UserChoice`, then the classes default, and launching the winning progid's `shell\open\command` reimplements an order Windows owns, including the `UserChoice` hash Windows validates; the shell's own answer is the definition of the application a double-click uses.
|
||||
|
||||
**`cmd /c start <path>`.** Rejected. It needs a command shell for the same resolution Explorer already performs, and it reported no application on the machine that reproduced the defect.
|
||||
|
||||
**Fall back to a fixed editor when no application resolves.** Rejected. A Windows installation always ships a usable editor, but choosing it in the opener would override the user's association and turn a missing association into a silently wrong application.
|
||||
|
||||
## Consequences
|
||||
|
||||
The settings sheet's open gesture and the artifact and workspace opens share one Windows behavior: the application a double-click would launch. A machine whose recorded default Explorer honors but `AssocQueryString` does not — the reported state — now opens the configured editor.
|
||||
|
||||
The opener can no longer distinguish "the shell opened it" from "the shell declined": Explorer reports 0 or 1 for both, so the Remote still answers `{ opened: true }`, and a machine with no handler shows the shell's chooser as its only feedback. Nothing here verifies that a window appeared.
|
||||
|
||||
An interactive desktop session is a precondition. In a non-interactive Windows session (a service, or a scheduled task without an interactive logon) `explorer.exe` invokes no association and still exits 1, so that exit code does not tell this case apart from a delegated open: an SSH session 0 on Windows 11 ARM64 build 26200 measured exit code 1 after about a second, where an `Invoke-Item` command resolved the association in-process. A Host started that way loses the capability and pays the wait, and a caller's abort signal is what bounds it. No fallback for that session is implemented.
|
||||
|
||||
Directories take the same path. The rejected alternative recorded in [the open-in-app note](../feature/2026-08-25-promote-open-anywhere-plugin.md) was a detached, credential-scrubbed `explorer.exe <dir>` spawn, which is not what this handoff does: the `explorer.exe` child inherits the host environment and no detached process is created, while the application or folder window Explorer delegates to starts from the desktop session's environment rather than the host's.
|
||||
|
||||
## Verification
|
||||
|
||||
`path-opener.spec.ts` pins the encoded file URI Explorer receives for both intents, the accepted delegate exit code 1 for an open and a reveal, and the preserved failure and cancellation. `resolver.spec.ts` pins the open-in-app route's Windows command. Native desktop verification stays on Windows, per the package's test policy. On Windows 11 ARM64 build 26200 an independent pass drove the module's own `openNativeAssociatedPath`, `openNativePath`, and `openNativeTextFile` from a sha256-matched copy beside an association probe that recorded every argument the shell passed: each comma-bearing target arrived complete as `%2C`, a plain file opened, and a directory named for a comma opened as a window rooted there. The negative control — the same file as a file URI whose comma is left raw — invoked no association and opened the user's Documents folder, and still exited 1. Exit 1 therefore records the delegation rather than the outcome, so accepting it cannot turn a wrong target into a success; the encoding is what keeps the path whole. Nothing here observes a window. A later pass on the same build read the shell's own window and selection state instead of pixels: the encoded `/select,` target selected the named file; an unencoded `=` opened Documents instead of the file; and a target whose non-ASCII characters stay percent-encoded opened Documents where the literal characters opened the file.
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Agent Note: Windows 打开经 explorer.exe 完成
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-22-windows-open-through-shell-resolution.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Windows 桌面客户端设置页的「打开配置文件」会准备当前 profile 的 `cordis.patch.yml`,并把该路径交给原生文本编辑器打开器(issue #4428)。在报告者机器上,这个动作既没有打开窗口,也没有任何提示。打开器执行的是 `powershell.exe -NoProfile -Command "Invoke-Item -LiteralPath '<path>'"`,因此由宿主进程内的关联解析决定结果,而 `Invoke-Item` 在解析不到应用时同样以退出码 0 结束。
|
||||
|
||||
该机器的 `.yml` 默认值只记录在 `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.yml\UserChoiceLatest`(progid 为 `VSCode.yml`),既没有 `UserChoice` 记录,也没有 `HKCU\Software\Classes\.yml` 默认值。对同一个文件双击,资源管理器会打开 VS Code。而在宿主进程中执行时,`Invoke-Item`、`cmd /c start`、`Shell.Application` 的默认动词和 `AssocQueryString` 都报告该扩展名没有应用,`cmd /c start` 则弹出 shell 自己的应用选择框而不是打开某个应用。`Invoke-Item` 仍然返回 0,于是 `SettingsController.openSettingsDocument` 回答 `{ opened: true }`,设置页不渲染任何错误。
|
||||
|
||||
## 决策
|
||||
|
||||
`openWindowsPath` 将路径作为**一个** argv 元素交给 `explorer.exe`,`revealNativePath` 经共享的 `runExplorer` 帮助函数走到同一个 shell。两者都把目标编码成文件 URI 并对**逗号与等号**做百分号编码,因为资源管理器自己解析命令行并以这两个字符分段——含其中任一字符的原始路径都会打开另一个目标且不作任何提示。其余字符不做转义:资源管理器会拒绝 file URI 里百分号编码的非 ASCII 并回落到「文档」,因此这些字符以字面形式交给它。资源管理器执行的是双击所执行的默认应用解析,因此由 shell 的答案选择应用——包括只记录在较新的按用户记录里的默认值;而完全没有处理程序的机器会得到资源管理器自己的「你要如何打开这个文件?」选择框,而不是毫无反应。
|
||||
|
||||
`runExplorer` 为两种操作持有唯一一条 Explorer 调用规则:退出码 1 是 Explorer 把请求转交给已在运行的桌面进程后返回的交接码,它照常兑现调用方的 Promise;其余失败和取消仍然报错。该帮助函数取代了 PowerShell 命令字符串,同时删掉了 `powershellLiteral` 及其单引号翻倍逻辑:路径以 argv 元素跨进程边界,去掉的是 shell 层转义,而不是资源管理器自身解析所需的编码;该编码由 `explorerTarget` 为两种意图统一持有。
|
||||
|
||||
Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` 继续拒绝该平台,默认意图、关联意图与文本编辑器意图都走同一个 Explorer 交接。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**继续使用 `Invoke-Item`。** 已否决。它询问的解析器与 shell 不同,因此即使某个用户的默认值能被资源管理器识别,仍会得到静默的无效操作;而且它的零退出码正好抹掉了设置页本应渲染的错误。
|
||||
|
||||
**在宿主进程内自行解析默认值。** 已否决。依次读取 `UserChoiceLatest`、`UserChoice` 和 classes 默认值,再启动胜出 progid 的 `shell\open\command`,等于重新实现一套 Windows 自己拥有的顺序,其中还包括 Windows 会校验的 `UserChoice` 哈希;而双击所用应用的定义就是 shell 自己的答案。
|
||||
|
||||
**使用 `cmd /c start <path>`。** 已否决。它为资源管理器本已执行的同一套解析引入一个命令 shell,并且在复现该缺陷的机器上同样报告没有应用。
|
||||
|
||||
**解析不到应用时回退到固定编辑器。** 已否决。Windows 安装总是带有可用的编辑器,但在打开器里选择它等于覆盖用户的关联,并把「缺少关联」变成「静默地打开错误应用」。
|
||||
|
||||
## 后果
|
||||
|
||||
设置页的打开动作与产物、工作区的打开动作共享同一个 Windows 行为:双击会启动的那个应用。对于「资源管理器认、`AssocQueryString` 不认」这一被报告的状态,现在会打开已配置的编辑器。
|
||||
|
||||
打开器再也无法区分「shell 打开了」与「shell 拒绝了」:两种情况 Explorer 都返回 0 或 1,因此 Remote 依旧回答 `{ opened: true }`,完全没有处理程序的机器只能看到 shell 的选择框。此处不校验窗口是否真的出现。
|
||||
|
||||
交互桌面会话现在是前提。在非交互 Windows 会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 不调用任何关联,且同样以退出码 1 返回,因此该退出码无法与「已委派」区分:Windows 11 ARM64 build 26200 的 SSH session 0 实测约 1 秒后返回退出码 1,而同一会话里 `Invoke-Item` 命令能在进程内解析关联。这样启动的 Host 会失去能力且要付出等待,只有调用方的 abort 信号能给这段等待封顶。该会话下的兜底尚未实现。
|
||||
|
||||
目录走同一条路径。[open-in-app 记录](../feature/2026-08-25-promote-open-anywhere-plugin.zh.md)中被否决的方案是 detached 且经过凭据清洗的 `explorer.exe <dir>` 派生进程,那不是本交接的做法:`explorer.exe` 子进程继承宿主环境,也不创建 detached 进程;而它转交给桌面会话启动的应用或文件夹窗口,继承的是桌面会话环境而不是宿主进程环境。
|
||||
|
||||
## 验证
|
||||
|
||||
`path-opener.spec.ts` 固定两种意图交给 Explorer 的编码文件 URI、打开与选中都可接受的交接退出码 1,以及仍然报错的普通失败与取消。`resolver.spec.ts` 固定 open-in-app 路由在 Windows 上的命令。按该包测试策略,原生桌面验证留在 Windows。在 Windows 11 ARM64 build 26200 上,一次独立验证用 sha256 与源码一致的副本直接驱动模块自身的 `openNativeAssociatedPath`、`openNativePath`、`openNativeTextFile`,旁边挂一个记录 shell 实收参数的探针关联:每个含逗号的目标都以 `%2C` 完整到达、普通文件正常打开、名为 `dir,comma` 的目录打开为根目录正确的窗口。反证——同一个文件以逗号未编码的 file URI 交给 `explorer.exe`——没有触发任何关联、打开了用户的「文档」,而且**同样返回退出码 1**。因此退出码 1 记录的是委派而非结果,接受它不会把错误目标变成成功;真正保住路径完整的是那段编码。此处不观测窗口是否出现。同一 build 的后续一轮改为直接读取 shell 自己的窗口与选中状态:编码后的 `/select,` 目标选中了目标文件;未编码的 `=` 会打开「文档」而不是文件;非 ASCII 字符保持百分号编码时同样打开「文档」,改为字面字符则正常打开。
|
||||
@@ -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-09-23-bounded-pnpm-runs.md
|
||||
2026-09-23-bounded-pnpm-runs.md: 7ce6b1776e4587240072201781c9da5bc78650e8
|
||||
2026-09-23-bounded-pnpm-runs.zh.md: 1cb91132cca22a1130fdc48ce3d3dbd354fa5d16
|
||||
@@ -0,0 +1,59 @@
|
||||
# Agent Note: Bounding a pnpm run that stops progressing
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-23-bounded-pnpm-runs.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A profile package operation could hold the profile write lock forever. A `dsh web` process held `~/.dsh/profiles/web/package.json.lock` for 22 minutes after its pnpm child printed `Done in 2s`: that child never exited, so the operation awaited an end that never came, the lock was never released, and every later management call in the process queued behind it. pnpm 11.13.0 consumes its one-shot worker-pool teardown once, re-creates the pool lazily afterwards, and unreferences a worker only during destruction, so the pool keeps the parent process alive with no repository-side change able to fix it.
|
||||
|
||||
A second cause reaches the same end by another route: a lifecycle script that inherited pnpm's stdout and stderr keeps those pipes open after pnpm exits, so a reader waiting for end of file never sees one, and the lock stays held while that reader waits.
|
||||
|
||||
## Decision
|
||||
|
||||
- A run completes when its process exits, not when its pipes end. The operation awaits the raw child's `exit` event and races execa's promise, which resolves only once the piped stdio closes.
|
||||
- The pipes then drain under a fixed 2000 ms grace period, after which this operation closes them. Failure classification reads a tail pnpm writes before it exits, so the grace period belongs to finishing a run rather than to deployment configuration.
|
||||
- Only the close this drain causes is ignored. A reading that failed before the cut still surfaces as the run's failure, and the log records that the tail was cut short.
|
||||
- A run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` beside its own exit status, and is not asked of the next registry, which bounds how long one operation can hold the lock.
|
||||
- A terminated run is classified `timeout` whatever exit status the signal left behind. Installation and removal report failure instead of success, so a manifest pnpm had half written is restored rather than activated.
|
||||
- A terminated run stops its whole process tree and waits for it to disappear before the caller restores the profile and releases the lock, because an approved lifecycle script runs as a child of pnpm and outlives it. execa's `killDescendants` addresses the tree on every kill path, including cancellation, and is passed only for a captured run, so a run that inherits the terminal keeps the caller's process group.
|
||||
- `dsh plugin` inherits the caller's terminal and captures nothing, so no silence bound applies to it and its operator keeps the ability to interrupt.
|
||||
|
||||
## Why a silence bound rather than a duration bound
|
||||
|
||||
A capture-only run that prints nothing for ten minutes is stuck: pnpm reports its own progress and its scripts' output there. A total duration bound would instead kill legitimate work, because a non-TTY pnpm prints a build script's output when that script finishes, and a native build on a slow machine compiles silently for longer than any fixed budget we could justify for it. Silence separates a stalled run from a slow one without guessing a duration, and 600000 ms leaves an approved build ten times the slowest silent stretch maintainers expect from it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Wait for execa's promise.** execa resolves after the piped stdio ends, which is the end a held pipe withholds, so the completion signal had to move to the process itself.
|
||||
|
||||
**Bound total duration instead of silence.** A stalled run and a compiling run are indistinguishable by duration alone, and the bound would destroy legitimate work whenever the machine is slow enough.
|
||||
|
||||
**Terminate only the pnpm process.** An approved `postinstall` keeps writing into the profile after the operation reports a timeout and releases the lock, so a later restoration races that script. Reproduced with a local dependency whose postinstall writes a file, and with pnpm 11.13.0.
|
||||
|
||||
**Detach the run and reap the tree later.** Rollback and lock release must happen after the tree stopped writing, and deferring the reap leaves open exactly the window this decision closes.
|
||||
|
||||
**Keep the pipes as the completion signal and bound only that wait.** The drain does bound the wait, but a stalled worker pool never ends its pipes, so the process exit remains the completion signal.
|
||||
|
||||
## Testing
|
||||
|
||||
| Evidence | Behaviour |
|
||||
|---|---|
|
||||
| [operations.spec.ts](../../../../packages/boot/plugin-manager/tests/operations.spec.ts) | Silence-bound termination, a run that trapped the signal and exited 0, the bounded drain over a held pipe, a reading failure that still surfaces through the cut, a rejection reason that is not an Error, and the cut notice. |
|
||||
| [operations-process.spec.ts](../../../../packages/boot/plugin-manager/tests/operations-process.spec.ts) | Real descendants: a pipe that outlives its process still drains, and a stalled run's tree is stopped before the operation returns, so a marker its script would have written never appears. |
|
||||
| [run-tree.spec.ts](../../../../packages/boot/plugin-manager/tests/run-tree.spec.ts) | Platform group-leadership decisions, the probe target each platform addresses, and the wait bound. |
|
||||
| [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) | A terminated run that exited 0 restores the manifest, reports `timeout`, and is not activated; a terminated removal reports failure. |
|
||||
|
||||
The real-process cases cover POSIX only: Windows cannot stage a descendant that keeps inherited pipes open past its parent, and the drain itself is platform-independent. With those cases skipped, the mocked cases still hold the per-file coverage gate on Windows.
|
||||
|
||||
## Related
|
||||
|
||||
[Guided plugin installation](../architecture/2026-09-15-guided-plugin-installation.md) remains the owner of what an installation or removal does with a finished run: the manifest snapshot it restores, the classification it renders, and the cancellation contract. This note owns only how a run ends and how long it may hold the profile lock.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A stalled run is reported up to `idleTimeoutMs` late and the profile lock is held for that long. A silent but healthy native build longer than the default is killed and classified `timeout`; a profile that compiles such a dependency raises `idleTimeoutMs`, trading slower stall detection for tolerance.
|
||||
- A cut tail is incomplete, so the failure classification and the returned output see only what pnpm wrote inside the grace period. The diagnostic log records the cut.
|
||||
- Terminating a run kills its whole tree, so a lifecycle script that had already started is stopped instead of being allowed to finish.
|
||||
- The bound is a workaround for a third-party defect (issue #4981, PR #4982) and becomes unnecessary once pnpm tears down its worker pool before a command returns.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Agent Note: 为停止推进的 pnpm 运行设界
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-23-bounded-pnpm-runs.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
一次 profile 包操作可能永久持有 profile 写锁。某个 `dsh web` 进程在其 pnpm 子进程打印 `Done in 2s` 之后仍持有 `~/.dsh/profiles/web/package.json.lock` 达 22 分钟:该子进程从未退出,于是操作等待一个永不到来的结束点,锁始终未释放,进程内后续每一次管理调用都排在它后面。pnpm 11.13.0 只消费一次性的 worker 池收尾,之后又惰性重建该池,并且只在销毁时才对 worker 取消引用,因此该池会让父进程一直存活,仓库侧无从修复。
|
||||
|
||||
第二条成因以另一条路径导致同一结果:继承了 pnpm stdout 与 stderr 的生命周期脚本会在 pnpm 退出后继续持有这些管道,于是等待文件结束的读取方永远等不到,锁在读等期间一直被持有。
|
||||
|
||||
## 决策
|
||||
|
||||
- 运行的完成点是进程退出,而非管道结束。操作等待原始子进程的 `exit` 事件,并与 execa 的 promise 竞速;后者只在管道化 stdio 关闭后才 resolve。
|
||||
- 随后管道在一个固定的 2000 毫秒宽限窗口内排空,窗口结束后由本次操作关闭它们。失败分类读取的是 pnpm 退出前写入的尾部,因此该宽限属于「完成一次运行」,而不是部署配置项。
|
||||
- 只有本次排空造成的关闭才被忽略。切断之前的读取失败仍作为该运行的失败上报,日志会记录尾部被切断。
|
||||
- 捕获输出的运行若在 `idleTimeoutMs` 内没有任何输出即被终止,在自身退出状态之外报告 `timedOut`,且不再询问下一个注册表,从而使单次操作持有锁的时长有上界。
|
||||
- 被终止的运行不论信号留下什么退出状态都归类为 `timeout`。安装与删除都报告失败而非成功,因此 pnpm 只写了一半的 manifest 会被还原,而不会被启用。
|
||||
- 被终止的运行会停止整棵进程树并等待其消失,之后调用方才还原 profile 并释放锁,因为获批的生命周期脚本是 pnpm 的子进程且存活更久。execa 的 `killDescendants` 覆盖每一条 kill 路径(含取消),且只对捕获输出的运行启用,因此继承终端的运行仍留在调用方的进程组内。
|
||||
- `dsh plugin` 继承调用方终端、不捕获输出,因此不受静默上界约束,其操作者仍可中断。
|
||||
|
||||
## 为何用静默上界而非时长上界
|
||||
|
||||
只捕获输出、十分钟没有任何输出的运行已经卡住:pnpm 自身的进度与脚本输出都写在那里。而总时长上界会误杀正当工作,因为非 TTY 的 pnpm 只在该脚本结束时才打印它的输出,慢机器上的原生构建以超过任何可辩护固定预算的时间静默编译。静默把卡住的运行与较慢的运行区分开,无需猜测时长;600000 毫秒给获批构建留下的静默余量,是维护者预期的最慢静默时长的十倍。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**等待 execa 的 promise。** execa 在管道化 stdio 结束后才 resolve,而那正是被持有管道扣住不发的结束点,因此完成信号必须移到进程本身。
|
||||
|
||||
**改为对总时长设界。** 仅凭时长无法区分卡住的运行与正在编译的运行,而且只要机器够慢,这个上界就会破坏正当工作。
|
||||
|
||||
**只终止 pnpm 进程。** 获批的 `postinstall` 会在操作报告超时并释放锁之后继续写入 profile,于是后续的还原与该脚本互相竞争。已用 pnpm 11.13.0 与一个 postinstall 会写文件的本地依赖复现。
|
||||
|
||||
**让运行脱离父进程、稍后再回收进程树。** 还原与释放锁必须在进程树停止写入之后发生,推迟回收恰好留下本决策要关闭的那个窗口。
|
||||
|
||||
**保留管道作为完成信号,只对该等待设界。** 排空确实为等待设了界,但卡住的 worker 池永不结束其管道,因此进程退出仍是完成信号。
|
||||
|
||||
## 验证
|
||||
|
||||
| 证据 | 覆盖行为 |
|
||||
|---|---|
|
||||
| [operations.spec.ts](../../../../packages/boot/plugin-manager/tests/operations.spec.ts) | 静默上界终止、捕获信号后以 0 退出的运行、持有管道下的有界排空、经切断仍上报的读取失败、非 Error 的拒绝原因,以及切断提示。 |
|
||||
| [operations-process.spec.ts](../../../../packages/boot/plugin-manager/tests/operations-process.spec.ts) | 真实孙进程:存活超过其父进程的管道仍能排空;卡住运行的进程树在操作返回前被停止,因此其脚本本应写入的标记不会出现。 |
|
||||
| [run-tree.spec.ts](../../../../packages/boot/plugin-manager/tests/run-tree.spec.ts) | 各平台的进程组归属判定、各平台探测的目标,以及等待上界。 |
|
||||
| [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) | 以 0 退出的被终止运行会还原 manifest、报告 `timeout` 且不被启用;被终止的删除报告失败。 |
|
||||
|
||||
真进程用例仅覆盖 POSIX:Windows 无法搭出「孙进程在其父进程退出后仍持有继承管道」的前提,而排空本身与平台无关。跳过这些用例时,mock 用例仍能在 Windows 上满足逐文件覆盖率门禁。
|
||||
|
||||
## 相关
|
||||
|
||||
[引导式插件安装](../architecture/2026-09-15-guided-plugin-installation.zh.md)仍是「安装或删除如何处理一次已结束运行」的归属:它还原的 manifest 快照、渲染的分类与取消约定。本记录只负责运行如何结束,以及它可以持有 profile 锁多久。
|
||||
|
||||
## 后果
|
||||
|
||||
- 卡住的运行最迟在 `idleTimeoutMs` 之后才被报告,profile 锁在这段时间内一直被持有。静默但健康、且超过默认值的原生构建会被终止并归类为 `timeout`;需要编译此类依赖的 profile 应提高 `idleTimeoutMs`,以更慢的卡住检测换取容忍度。
|
||||
- 被切断的尾部不完整,因此失败分类与返回输出只看到 pnpm 在宽限窗口内写入的内容。诊断日志记录了这次切断。
|
||||
- 终止运行会杀掉整棵进程树,因此已经开始的生命周期脚本会被停止,而不会被允许跑完。
|
||||
- 该上界是针对第三方缺陷的变通(issue #4981、PR #4982);一旦 pnpm 在命令返回前完成其 worker 池收尾,它就不再必要。
|
||||
@@ -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-07-mcp-client-plugin.md
|
||||
2026-07-07-mcp-client-plugin.md: a4aa2cf216d9f28afedebf82f0659db70606bbf1
|
||||
2026-07-07-mcp-client-plugin.zh.md: 5903e4ef976317602527d9f335d640bc86939f72
|
||||
2026-07-07-mcp-client-plugin.md: dd793b67bf2043fa88377d50837df40faadf3fbf
|
||||
2026-07-07-mcp-client-plugin.zh.md: 5056118df0185f1e7ec2b1ea06818ed920d2af26
|
||||
@@ -145,7 +145,7 @@ A unified `execute` handler for all tools from one MCP server:
|
||||
1. Call the SDK's `callTool` with the closed-over `rawName`, model arguments, `exec.signal`, the configured timeout, and the complete discovered `toolDefinition` — the public name is never sent to the server.
|
||||
2. Preserve canonical success as `{ content: JsonValue[], structuredContent? }`; complete MCP JSON blocks remain the programmatic/PTC mode value. `isError: true` throws before any image persistence so the registry owns the failure path.
|
||||
3. Prepare a separate ordered Native projection. Text runs join with `'\n'`; resource links preserve name and URI as text; audio and embedded resources become explicit diagnostics. The SDK rejects malformed wire results. If any image exists, the bridge strictly decodes the complete batch, resolves the calling agent's latest exact route, requires an attachment store plus explicit model image input, and delegates all-member validation and ordered persistence to `AttachmentStore.saveImages()`. Any decode, capability, or storage refusal renders every image as diagnostic text and returns no partial references.
|
||||
4. Keep `output.render` synchronous and pure. The executor stages its richer projection in a generation-local `WeakMap` keyed by the exact execution; `finalizeContent` installs it only when the registry's post-execute result still has the original canonical value and fallback content. A policy block, value replacement, or content replacement remains authoritative, and a re-sync cannot let an older generation consume new execution state.
|
||||
4. The executor preserves the complete canonical MCP value and prepares ordered text/image content. `projectContent` installs that content before `tools/post-execute`, so retention sees real images. Subsequent content replacement, value replacement, and blocking remain authoritative.
|
||||
5. PTC mode receives the untouched canonical value. Its generic dispatch bridge defers a successful final content sequence containing an image through the outer `run_code` result, so MCP requires no private parent-token special case.
|
||||
6. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, exact-model lookup, and the pre-storage gate.
|
||||
|
||||
@@ -157,6 +157,8 @@ Build the child environment from the subprocess seam's shared `scrubbedParentEnv
|
||||
|
||||
A per-instance connection supervisor reconnects automatically after a lost connection with bounded exponential backoff and a per-outage attempt budget, re-running discovery on success; exhaustion unregisters the server's tools and stops until reload. The [auto-reconnect Agent Note](../../archived/feature/2026-08-06-mcp-client-auto-reconnect.md) owns that decision, including the `reconnect` config block and the `reconnect.enabled: false` opt-out that restores manual HMR/restart recovery.
|
||||
|
||||
The [multimodal retention decision](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.md) explains why prepared images enter content before result policies.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### MCP Server side (expose harness tools to external MCP clients)
|
||||
|
||||
@@ -145,7 +145,7 @@ SDK 接受符合协议的工具,并执行现代 HTTP header 声明检查。注
|
||||
1. 调用 SDK 的 `callTool`,传入闭包持有的 `rawName`、模型参数、`exec.signal`、配置的超时时间与完整的已发现 `toolDefinition`——公开名称永远不发送给服务器。
|
||||
2. 把规范成功值保留为 `{ content: JsonValue[], structuredContent? }`;完整 MCP JSON 块仍是程序化调用/PTC mode 值。`isError: true` 会在持久化任何图片前抛出,使失败路径归注册表所有。
|
||||
3. 另行准备有序 Native 投影。连续文本块以 `'\n'` 连接;资源链接以文本保留名称和 URI;音频和嵌入资源成为明确诊断。SDK 拒绝格式错误的协议结果。只要存在图片,桥接层就严格解码完整批次,解析调用 agent 的最新确切路由,要求附件存储以及模型明确支持图片输入,再把全成员校验和有序持久化委托给 `AttachmentStore.saveImages()`。任何解码、能力或存储拒绝都会把全部图片渲染为诊断文本,且不返回部分引用。
|
||||
4. 保持 `output.render` 同步且纯净。执行器把更丰富的投影暂存在按同步世代创建、以确切执行为键的 `WeakMap` 中;只有注册表的 post-execute 结果仍保留原规范值和兜底内容时,`finalizeContent` 才安装该投影。策略阻止、值替换或内容替换仍具有权威性,重新同步也无法让旧世代消费新执行状态。
|
||||
4. 执行器保留完整的规范 MCP 返回值,并准备有序图文内容。`projectContent` 在 `tools/post-execute` 之前安装已准备的内容,让省略策略看到真实图片块。后续策略的内容替换、值替换和阻止均保持生效。
|
||||
5. PTC mode 接收未改动的规范值。其通用分发桥接层会把包含图片的成功最终内容序列经外层 `run_code` 结果延后,因此 MCP 无需私有父 token 特例。
|
||||
6. 取消:`exec.signal`(来自 agent loop 的取消)透传给 MCP SDK 的 `callTool`、确切模型查询和存储前门禁。
|
||||
|
||||
@@ -157,6 +157,8 @@ SDK 接受符合协议的工具,并执行现代 HTTP header 声明检查。注
|
||||
|
||||
每个实例的连接监督器在连接丢失后以有界指数退避和单次故障尝试预算自动重连,成功后重新执行发现流程;尝试耗尽则注销该服务器的工具并停止,直到重新加载。[自动重连 Agent Note](../../archived/feature/2026-08-06-mcp-client-auto-reconnect.md) 拥有该决策,包括 `reconnect` 配置块和恢复手动 HMR/重启恢复的 `reconnect.enabled: false` opt-out。
|
||||
|
||||
图片在结果策略之前进入内容的原因见[图文结果保留决策](../../implemented/bug-fix/2026-09-21-multimodal-tool-result-retention.zh.md)。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
### MCP Server 端(将 harness 工具暴露给外部 MCP 客户端)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md
|
||||
2026-08-05-agent-teams.md: 45c7e599e03bb26dc9224c74a6d3bc0ab8a74dcc
|
||||
2026-08-05-agent-teams.zh.md: dbb2931bdc40da5ddf2c8091dd8c401029e0445f
|
||||
2026-08-05-agent-teams.md: 6333e511ba1e71823cfa55c27ba46b2665d1ef15
|
||||
2026-08-05-agent-teams.zh.md: 3fed1302146406f71d63c7ff2df8f031a91c9804
|
||||
@@ -54,6 +54,14 @@ All members use the same cwd and observe writes immediately. The policy tells me
|
||||
|
||||
Worktree isolation is not a harness runtime behavior. A deployment or prompt may arrange separate worktrees, but the Team domain does not infer branches, merge changes, or silently change cwd. This preserves the existing same-world subagent and sandbox contracts.
|
||||
|
||||
## Web projection
|
||||
|
||||
The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Both Lead and teammate conversations select values from the shared cross-Session store. The panel owns no projection reads: Session loading, cached list values, and control baselines supply the data.
|
||||
|
||||
The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; the panel displays the shared store's available values without opening member conversations. A missing cached value remains absent until Session loading or a live update supplies it.
|
||||
|
||||
Publication requires `apply` to return a new state object per applied Team event and replace only the touched collection. Mailbox-only events retain the view reference and publish nothing. The existing carrier sends each changed complete view to every connected browser; the package README records that cost.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Extend direct-child subagent tools with peer ids.** Rejected because parent/child authority and Team peer membership are different domains. Adding peer access to the continuation seam would weaken its exact-parent authorization and still leave roster and tasks without a persistence owner.
|
||||
|
||||
@@ -54,6 +54,14 @@ Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/messag
|
||||
|
||||
Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以安排独立 worktree,但 Team 领域不会推断 branch、merge 变更或静默改变 cwd。这样保留既有 same-world subagent 与 sandbox 契约。
|
||||
|
||||
## Web projection
|
||||
|
||||
Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。Lead 与 teammate 会话均从共享的跨 Session store 选择值。面板不负责投影读取:Session 加载、列表缓存值与控制基线提供数据。
|
||||
|
||||
传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;面板展示共享 store 中可用的值,不打开成员会话。缺少的缓存值保持缺失,直到 Session 加载或实时更新提供它。
|
||||
|
||||
发布要求 `apply` 对每个已应用的 Team 事件返回新的 state 对象,并只替换被触及的集合。仅邮箱的事件保留视图引用,不发布 frame。现有传输层将每个变化后的完整视图发送给所有已连接浏览器;包 README 记录了这项开销。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**用 peer id 扩展 direct-child subagent tool。** 拒绝,因为 parent/child 权限与 Team peer membership 是不同领域。向 continuation seam 增加 peer access 会削弱 exact-parent authorization,仍无法为 roster 与 task 提供持久 owner。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md
|
||||
2026-08-11-background-job-completion-wakes-an-idle-owner.md: b8a10a2150ead02036cb97506a25c3a51202adcf
|
||||
2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: adc0770cd00adeb3b13c95dcc386464493dcd943
|
||||
2026-08-11-background-job-completion-wakes-an-idle-owner.md: f13f09d326dc7b4e7b2998f3124f70c185c50922
|
||||
2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: f9b563ebbf90e2ca19d1fc5ca9fa5b028e4429f1
|
||||
+7
-7
@@ -28,11 +28,11 @@ For a driver that is genuinely running, `steer()` and `inject()` are the same de
|
||||
|
||||
Injection is correct there. A cancelled turn is a user pressing stop, and reopening one on their behalf launders an interrupt into a model request they did not ask for. The turn loop already covers the ordinary case: it cannot close while the next-step inbox holds anything, so a notice arriving before that check extends the current turn, and several tasks settling together cost one step rather than one turn each.
|
||||
|
||||
### Waking is bounded, and the bound is not time
|
||||
### The cap on consecutive wakes is opt-in, and it is not time
|
||||
|
||||
`maxConsecutiveWakes` (default 3) caps the turns one owner may open this way; beyond it a notice degrades to injection and waits for the next turn. Claiming any user-authored message restores the budget — claiming, not arrival, because that is the point human input actually enters a step. Notices this plugin queued never refill it.
|
||||
Waking is unbounded unless `maxConsecutiveWakes` is set. With it set, the value caps the turns one owner may open this way; beyond it a notice degrades to injection and waits for the next turn. Claiming any user-authored message restores the budget — claiming, not arrival, because that is the point human input actually enters a step. Notices this plugin queued never refill it.
|
||||
|
||||
The bound exists because this chain is self-exciting in a way subagent settlement is not. Settlement is bounded by how many children the model spawned; a woken turn can start the background job whose completion wakes it again, with nobody watching. `dsh run` needs no separate policy: its one user message is claimed in the first turn and never repeats, so the budget is spent monotonically and the process terminates.
|
||||
The cap exists because this chain is self-exciting in a way subagent settlement is not. Settlement is bounded by how many children the model spawned; a woken turn can start the background job whose completion wakes it again, with nobody watching. It has no default because a notice past the cap stalls the session invisibly; the [unbounded-by-default decision](2026-09-22-unbounded-completion-wakes-by-default.md) records that trade-off. `dsh run` needs no separate policy either way: headless exits once the task turn goes idle, so a completion after that point has no owner to wake.
|
||||
|
||||
`completionDelivery: quiet` restores the old lane for idle owners. It exists for deterministic transcripts; job completion independently retains `quiet | wakeup` because its bounded owner-turn policy differs from next-step subagent reports.
|
||||
|
||||
@@ -52,15 +52,15 @@ The bound exists because this chain is self-exciting in a way subagent settlemen
|
||||
|
||||
**A general unsolicited-input queue** with priority lanes, as Claude Code uses to merge background jobs, cron, MCP push, and hooks into one drain. DSH's inbox already is that queue — durable `agent/inbox/spliced` splices over `next-turn`/`next-step` — so this would add a layer above an existing one to decide a single bit.
|
||||
|
||||
**Refusing to reopen a turn that already produced a visible answer,** Codex's `MailboxDeliveryPhase` latch. That latch is the default this decision deliberately inverts: waking after the model has spoken is the entire point, and the wake budget is the bound instead.
|
||||
**Refusing to reopen a turn that already produced a visible answer,** Codex's `MailboxDeliveryPhase` latch. That latch is the default this decision deliberately inverts: waking after the model has spoken is the entire point, and the optional wake budget is the bound instead.
|
||||
|
||||
**A wall-clock window** on top of the counter. For an interactive agent the slow case is the wanted one — an hour-long build finishing and the agent resuming is the feature — and `dsh run` is already bounded by the counter it cannot refill. Worth revisiting only if an unattended long-lived deployment appears.
|
||||
**A wall-clock window** on top of the counter. For an interactive agent the slow case is the wanted one — an hour-long build finishing and the agent resuming is the feature — and `dsh run` exits once its task turn goes idle regardless. Worth revisiting only if an unattended long-lived deployment appears.
|
||||
|
||||
**Suppressing `onJobDone` entirely during owner drain,** symmetric with the service-wide `listenersClosed`. It reads cleaner and removes a signal that is not only for notices: the force-fail record and the runtime invariant both observe teardown settlements. The `reported` bit denies exactly the reporters and nothing else.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Default behavior changes: an idle owner now spends a model request per completion, capped at `maxConsecutiveWakes` per owner between user messages. Deployments that want the old behavior set `completionDelivery: quiet`.
|
||||
- Default behavior changes: an idle owner now spends a model request per completion, capped per owner between user messages only where `maxConsecutiveWakes` is set. Deployments that want the old behavior set `completionDelivery: quiet`.
|
||||
- The `tool-jobs` prompt section needs no edit; "You are notified in-session when a task finishes" became true rather than aspirational.
|
||||
- `JobSnapshot.reported` gains teardown as a fourth setter, documented at the Service Definition and in [the subsystem reference](../../../../docs/subsystems/jobs.md).
|
||||
- `settle()` announces completion after committing the record and publishing the visible-set change. Any listener relying on running before waiters were released or before `onJobsChanged` now runs after both.
|
||||
@@ -69,7 +69,7 @@ The bound exists because this chain is self-exciting in a way subagent settlemen
|
||||
|
||||
### Accepted risks
|
||||
|
||||
A spent budget is restored only by user input. An unattended agent that exhausts it collects its remaining notices whenever something else opens a turn, and nothing re-arms it in the meantime.
|
||||
Under a configured `maxConsecutiveWakes`, a spent budget is restored only by user input. An unattended agent that exhausts it collects its remaining notices whenever something else opens a turn, nothing re-arms it in the meantime, and the client shows nothing while the notice waits. This is why the cap has no default.
|
||||
|
||||
A notice pending on an idle owner under `quiet` still dies with that owner's disposal, unchanged from before: the disposal cancel clears the unclaimed inbox and the log keeps the insert/cancel pair as the record. The [settlement delivery note](2026-08-06-manager-owned-subagent-settlement-delivery.md) owns the offline-mailbox discussion this would need.
|
||||
|
||||
|
||||
+7
-7
@@ -28,11 +28,11 @@ Update:本文依赖的 `reported` 位与 `onJobDone` 已随 [jobs seam 收敛]
|
||||
|
||||
在那里注入才是对的。轮次被取消意味着用户按了停止,替他们重新开一轮等于把一次中断洗成了他们没有要求的模型请求。普通情形已由轮次循环覆盖:只要 next-step inbox 还有内容,轮次就无法结束,因此在该检查之前抵达的通知会延长当前轮次,同时结算的多个任务只花掉一步而不是各占一轮。
|
||||
|
||||
### 唤醒有界,且该界不是时间
|
||||
### 连续唤醒上限是可选的,且该界不是时间
|
||||
|
||||
`maxConsecutiveWakes`(默认 3)限制一个所有者由此开启的轮数;超出后通知降级为注入,等待下一轮。领取任何用户撰写的消息都会恢复预算——是领取而非抵达,因为那才是人类输入真正进入某一步的时刻。本插件自己排队的通知永远不会补充它。
|
||||
除非设置了 `maxConsecutiveWakes`,唤醒没有上限。设置后,该值限制一个所有者由此开启的轮数;超出后通知降级为注入,等待下一轮。领取任何用户撰写的消息都会恢复预算——是领取而非抵达,因为那才是人类输入真正进入某一步的时刻。本插件自己排队的通知永远不会补充它。
|
||||
|
||||
设界是因为这条链会自激,而 subagent 结算不会。结算受限于模型派生了多少子 agent;被唤醒的一轮却可能启动某个后台任务,而它的完成又会唤醒同一个所有者,且无人旁观。`dsh run` 不需要单独策略:它唯一的用户消息在第一轮就被领取且不会重复,因此预算单调消耗,进程必然终止。
|
||||
设上限是因为这条链会自激,而 subagent 结算不会。结算受限于模型派生了多少子 agent;被唤醒的一轮却可能启动某个后台任务,而它的完成又会唤醒同一个所有者,且无人旁观。它没有默认值,因为超出上限的通知会让会话不可见地停摆;[默认无上限的决策](2026-09-22-unbounded-completion-wakes-by-default.zh.md)记录了这项取舍。`dsh run` 无论如何都不需要单独策略:headless 在任务轮次空闲后即退出,此后的完成没有可唤醒的所有者。
|
||||
|
||||
`completionDelivery: quiet` 为空闲所有者恢复旧通道。它的存在是为了确定性 transcript;后台任务完成会独立保留 `quiet | wakeup`,因为其有界的所有者轮次策略不同于 next-step subagent 报告。
|
||||
|
||||
@@ -52,15 +52,15 @@ Update:本文依赖的 `reported` 位与 `onJobDone` 已随 [jobs seam 收敛]
|
||||
|
||||
**一个通用的非请求输入队列**并带优先级通道,正如 Claude Code 用来把后台任务、cron、MCP 推送与 hook 合并进同一次排空。DSH 的 inbox 本身就是那个队列——`next-turn`/`next-step` 之上的持久 `agent/inbox/spliced` splice——因此这等于在既有层之上再加一层,只为决定一个 bit。
|
||||
|
||||
**拒绝重开一个已经产出可见答复的轮次**,即 Codex 的 `MailboxDeliveryPhase` 闩锁。那条闩锁正是本决策刻意反转的默认值:在模型已经说完话之后唤醒它就是本特性的全部意义,界由唤醒预算来承担。
|
||||
**拒绝重开一个已经产出可见答复的轮次**,即 Codex 的 `MailboxDeliveryPhase` 闩锁。那条闩锁正是本决策刻意反转的默认值:在模型已经说完话之后唤醒它就是本特性的全部意义,界由可选的唤醒预算来承担。
|
||||
|
||||
**在计数之上再加墙钟窗口**。对交互式 agent 而言,慢的那种情形恰恰是想要的——一小时的构建结束、agent 接着干下去,这就是特性本身——而 `dsh run` 已被它无法补充的计数封顶。只有当出现无人值守的长生命周期部署时才值得重新考虑。
|
||||
**在计数之上再加墙钟窗口**。对交互式 agent 而言,慢的那种情形恰恰是想要的——一小时的构建结束、agent 接着干下去,这就是特性本身——而 `dsh run` 无论如何都在任务轮次空闲后退出。只有当出现无人值守的长生命周期部署时才值得重新考虑。
|
||||
|
||||
**在 owner 排空期间整体压制 `onJobDone`**,与服务级的 `listenersClosed` 对称。它读起来更干净,但会移走一个不只服务于通知的信号:强制失败记录与运行时不变量都会观察 teardown 结算。`reported` 位恰好只否决报告方,别的什么也不否决。
|
||||
|
||||
## 影响
|
||||
|
||||
- 默认行为改变:空闲所有者现在每次完成会花掉一次模型请求,按所有者、在两次用户消息之间由 `maxConsecutiveWakes` 封顶。想要旧行为的部署设置 `completionDelivery: quiet`。
|
||||
- 默认行为改变:空闲所有者现在每次完成会花掉一次模型请求,只有设置了 `maxConsecutiveWakes` 时才按所有者、在两次用户消息之间封顶。想要旧行为的部署设置 `completionDelivery: quiet`。
|
||||
- `tool-jobs` 的提示词段落无需改动;「任务完成时你会在会话内收到通知」从愿景变成了事实。
|
||||
- `JobSnapshot.reported` 新增 teardown 作为第四个置位方,记录在 Service Definition 与[子系统参考](../../../../docs/subsystems/jobs.zh.md)中。
|
||||
- `settle()` 在提交记录并发布可见集变更之后才宣布完成。任何依赖「在释放等待方之前或在 `onJobsChanged` 之前运行」的监听器现在都排在两者之后。
|
||||
@@ -69,7 +69,7 @@ Update:本文依赖的 `reported` 位与 `onJobDone` 已随 [jobs seam 收敛]
|
||||
|
||||
### 已接受的风险
|
||||
|
||||
已花掉的预算只由用户输入恢复。耗尽预算的无人值守 agent 要等到其他原因开启轮次时才收走剩余通知,在此期间没有任何机制为它重新充能。
|
||||
在设置了 `maxConsecutiveWakes` 的情况下,已花掉的预算只由用户输入恢复。耗尽预算的无人值守 agent 要等到其他原因开启轮次时才收走剩余通知,在此期间没有任何机制为它重新充能,通知等待时客户端也毫无显示。这正是上限没有默认值的原因。
|
||||
|
||||
在 `quiet` 下待领于空闲所有者的通知仍会随该所有者释放而消亡,与此前一致:释放时的取消会清空未领取的 inbox,日志保留插入/取消这一对作为记录。[结算交付 note](2026-08-06-manager-owned-subagent-settlement-delivery.zh.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/feature/2026-08-25-promote-open-anywhere-plugin.md
|
||||
2026-08-25-promote-open-anywhere-plugin.md: 83888cb548046cd8e023cd2b7c87f123cda0ed0f
|
||||
2026-08-25-promote-open-anywhere-plugin.zh.md: 4ef476ce7043d2dcee05dec9f604849741ce5e96
|
||||
2026-08-25-promote-open-anywhere-plugin.md: e5a435d24c2400b68fad8cccb094b176ad83f590
|
||||
2026-08-25-promote-open-anywhere-plugin.zh.md: 108091f3e7c2736063d67bc6b2d7fe0c039b35db
|
||||
@@ -21,7 +21,7 @@ The feature's first-party owners are `@deepseek-ai/dsh-host-open-in-app` at `pac
|
||||
- **Resolution sources are platform-honest and cheap.** macOS checks the known application directories (`/Applications`, `~/Applications`) for the entry's bundle spellings and launches `open -a <resolved bundle>`; Xcode follows `xcode-select -p`. Windows reads `App Paths`, then Uninstall records kept only when they prove an executable on disk, then known paths and the newest versioned install directory where an application uses one — one batched `reg.exe query` per root per pass. GitHub Desktop resolves its versioned executable together with the packaged `cli.js` and invokes the supported `github open <path>` behavior without a command shell. CLI names resolve in-process through `ctx.subprocess.resolveExecutable()` (PATH/PATHEXT stat, no shell, no `which`/`where.exe` children); Linux GUI entries whose CLI is off PATH fall back to their XDG desktop entry's verified `TryExec`/`Exec`, while `xdg-open` is offered only when the host announces a display server. The remaining host commands (`xcode-select`, `reg.exe`, icon extraction) run through `@deepseek-ai/dsh-native-command` (argv, never a shell).
|
||||
- **Three independent deadlines replace the single `commandTimeoutMs`**: `probeTimeoutMs` (resolution commands), `iconTimeoutMs` (icon extraction commands), and `launchWatchMs` (the early-failure watch window), so tuning one operation never changes another's response time — the shipped bundle keeps conservative 10 s command deadlines (timeouts are failure bounds, not latency budgets) with a 1 s watch window, which is what bounds how long the open route and the button's busy dress hold a successful launch. Launches spawn detached with a credential-scrubbed environment (`scrubbedParentEnv` from `dsh-subprocess`) plus explicit adapter entries; Windows GUI launchers remain visible unless an adapter explicitly hides its CLI process because that process opens the GUI separately. A launcher still running when the watch window closes counts as launched and is never killed or awaited (kitty and the JetBrains IDEs stay in the foreground for their window lifetime).
|
||||
- **Icons extract on every platform with a host source.** macOS converts the resolved bundle's `.icns` to a 128px PNG (`plutil` + `sips`); Windows extracts the resolved executable's associated icon as a 32px PNG through a generated PowerShell script run with positional `-File` args (no command-line path parsing); Linux follows the spec's desktop entry `Icon=` into the hicolor theme and pixmaps directories (PNG or SVG, filesystem only). Any failure answers 404 and the browser keeps its generic glyph.
|
||||
- **The launch catalog is a data table with per-platform entries** (`OPEN_IN_APP_CATALOG`): each application id declares, per platform (`darwin`/`win32`/`linux`), a chain of launcher sources tried in order — `fixed` (ships with the OS), `app` (known-directory bundle spellings), `xcode` (`xcode-select -p`), `cli` (in-process PATH resolution), `file` (first existing candidate under `${VAR}`/`~/` expansion), `scan` (newest versioned install directory), `app-paths` and `install-record` (the Windows registry tiers), `github-desktop` (the versioned executable plus packaged CLI), and `desktop` (Linux XDG desktop entries) — plus a launcher argv template (a `{path}` token carries the directory in place, otherwise it is appended), optional environment and Windows visibility policy, and an optional fallback launcher (Xcode's `open -a <bundle>` behind `xed`). The whitelist follows Codex's "Open In" target list: editors and IDEs (VS Code, VS Code Insiders, Cursor, Windsurf, Zed, Sublime Text, Xcode, Android Studio, seven JetBrains IDEs), the promoted plugin's Git GUIs, terminals (Terminal, iTerm2, Ghostty, Warp, kitty, Windows Terminal, Git Bash, GNOME Terminal, Konsole), and per-platform file managers. File managers and platform terminals are separate ids (`finder`/`explorer`/`filemanager`) rather than one id with per-platform labels, because labels are static browser dictionary entries and only one of them probes as available per host. The file-manager entries launch through `dsh-native-command`'s path opener itself (`shell-open`, the OS shell's open verb with the full parent environment, not a detached scrubbed spawn), because a direct `explorer.exe <dir>` spawn does not reliably raise a window. Closed unions end in `assertNever`.
|
||||
- **The launch catalog is a data table with per-platform entries** (`OPEN_IN_APP_CATALOG`): each application id declares, per platform (`darwin`/`win32`/`linux`), a chain of launcher sources tried in order — `fixed` (ships with the OS), `app` (known-directory bundle spellings), `xcode` (`xcode-select -p`), `cli` (in-process PATH resolution), `file` (first existing candidate under `${VAR}`/`~/` expansion), `scan` (newest versioned install directory), `app-paths` and `install-record` (the Windows registry tiers), `github-desktop` (the versioned executable plus packaged CLI), and `desktop` (Linux XDG desktop entries) — plus a launcher argv template (a `{path}` token carries the directory in place, otherwise it is appended), optional environment and Windows visibility policy, and an optional fallback launcher (Xcode's `open -a <bundle>` behind `xed`). The whitelist follows Codex's "Open In" target list: editors and IDEs (VS Code, VS Code Insiders, Cursor, Windsurf, Zed, Sublime Text, Xcode, Android Studio, seven JetBrains IDEs), the promoted plugin's Git GUIs, terminals (Terminal, iTerm2, Ghostty, Warp, kitty, Windows Terminal, Git Bash, GNOME Terminal, Konsole), and per-platform file managers. File managers and platform terminals are separate ids (`finder`/`explorer`/`filemanager`) rather than one id with per-platform labels, because labels are static browser dictionary entries and only one of them probes as available per host. The file-manager entries launch through `dsh-native-command`'s path opener itself (`shell-open`, which hands the directory to the OS shell with the full parent environment, not a detached scrubbed spawn), because a detached, credential-scrubbed `explorer.exe <dir>` spawn does not reliably raise a window; the opener's own `explorer.exe` handoff does ([Windows open resolution](../bug-fix/2026-09-22-windows-open-through-shell-resolution.md)). Closed unions end in `assertNever`.
|
||||
- **Every route runs behind the composition connection service's trust fence** (`requestRejection`: the Host/Origin fence defeating DNS rebinding and cross-site calls, plus browser authentication), the same guard the API gateway applies to its WebSocket upgrade; the mechanism's one home is the `src/index.ts` module comment. On top of that fence the open route validates the body at the wire: an `application/json` media type (exact essence, not a substring match), a 64 KiB bound with a drained 413, string field types, only probed-available catalog ids, and an absolute path naming an existing directory.
|
||||
- **The DSH-version gate is deleted.** It existed because the plugin rode release-to-release against an interface it did not own; a first-party package is versioned with the repository, so the gate, its `sessionStorage`/`localStorage` trust ledger, and the argv-walking version probe have no referent.
|
||||
- **The last choice persists through `createSnapshotStore(..., { persist })`** (`dsh.open-in-app.choice`), replacing hand-rolled `localStorage` access. A fresh store has no platform-specific choice; the component uses the first available host entry until the user chooses one.
|
||||
|
||||
@@ -21,7 +21,7 @@ Status: implemented
|
||||
- **解析来源按平台务实且廉价。** macOS 在已知应用目录(`/Applications`、`~/Applications`)查条目的 bundle 拼写,启动 `open -a <解析出的 bundle>`;Xcode 跟随 `xcode-select -p`。Windows 依次读 `App Paths`、只在能证明磁盘可执行文件时采用的 Uninstall 记录、已知路径,以及采用版本化安装目录的应用中最新的目录——每趟每根一条批量 `reg.exe query`。GitHub Desktop 会同时解析版本化可执行文件与随包提供的 `cli.js`,不经命令 shell 调用受支持的 `github open <path>` 行为。CLI 名称经 `ctx.subprocess.resolveExecutable()` 进程内解析(PATH/PATHEXT stat,无 shell、无 `which`/`where.exe` 子进程);CLI 不在 PATH 上的 Linux GUI 条目回退到其 XDG desktop 条目验证过的 `TryExec`/`Exec`,且只有主机声明了 display server 时才提供 `xdg-open`。其余主机命令(`xcode-select`、`reg.exe`、图标提取)经 `@deepseek-ai/dsh-native-command`(argv,绝不走 shell)执行。
|
||||
- **三个独立期限取代单一 `commandTimeoutMs`**:`probeTimeoutMs`(解析命令)、`iconTimeoutMs`(图标提取命令)、`launchWatchMs`(早期失败看护窗口),调整一种操作的超时不再改变其他操作的响应时间——随发行 bundle 保守地保留 10 秒命令期限(超时是失败上界而非延迟预算),看护窗口 1 秒,它才是约束 open 路由与按钮忙碌态挂起一次成功启动时长的量。启动以清理过凭据的环境(`dsh-subprocess` 的 `scrubbedParentEnv`)叠加适配器显式环境后 detached 派生;Windows GUI launcher 默认保持可见,只有负责另行打开 GUI 的 CLI 适配器会显式隐藏自己的进程。看护窗口关闭时仍在运行的启动器计为已启动,绝不会被杀死或等待(kitty 与 JetBrains IDE 在整个窗口生命周期内保持前台)。
|
||||
- **有主机来源的平台都提取图标。** macOS 把解析出的 bundle 的 `.icns` 转 128px PNG(`plutil` + `sips`);Windows 用生成的 PowerShell 脚本以位置式 `-File` 参数(路径不经命令行解析)提取解析出的可执行文件的关联图标为 32px PNG;Linux 沿 spec 的 desktop 条目 `Icon=` 查 hicolor 主题与 pixmaps 目录(PNG 或 SVG,纯文件系统)。任何失败应答 404,浏览器保持通用占位图形。
|
||||
- **启动目录是按平台声明条目的数据表**(`OPEN_IN_APP_CATALOG`):每个应用 id 按平台(`darwin`/`win32`/`linux`)声明一条按序尝试的启动器来源链——`fixed`(随系统内置)、`app`(已知目录的 bundle 拼写)、`xcode`(`xcode-select -p`)、`cli`(进程内 PATH 解析)、`file`(`${VAR}`/`~/` 展开后第一个存在的候选文件)、`scan`(带版本号安装目录取最新)、`app-paths` 与 `install-record`(Windows 注册表两层)、`github-desktop`(版本化可执行文件与随包 CLI)、`desktop`(Linux XDG desktop 条目)——加启动器 argv 模板(`{path}` token 原位携带目录,否则目录追加在末尾)、可选环境与 Windows 可见性策略,以及可选回退启动器(`xed` 之后的 `open -a <bundle>`)。白名单对齐 Codex 的 "Open In" 目标列表:编辑器与 IDE(VS Code、VS Code Insiders、Cursor、Windsurf、Zed、Sublime Text、Xcode、Android Studio、七个 JetBrains IDE)、转正插件原有的 Git GUI、终端(Terminal、iTerm2、Ghostty、Warp、kitty、Windows Terminal、Git Bash、GNOME Terminal、Konsole)与各平台文件管理器。文件管理器与平台终端使用独立 id(`finder`/`explorer`/`filemanager`),而非一个 id 配平台标签,因为标签是静态浏览器词典条目,且每台主机只会探测到其中一个。文件管理器条目直接经 `dsh-native-command` 的路径打开器启动(`shell-open`,OS shell 的 open verb,携带完整父环境,而非 detached 的清理环境 spawn),因为直接 spawn `explorer.exe <dir>` 不能可靠地弹出窗口。封闭 union 以 `assertNever` 收尾。
|
||||
- **启动目录是按平台声明条目的数据表**(`OPEN_IN_APP_CATALOG`):每个应用 id 按平台(`darwin`/`win32`/`linux`)声明一条按序尝试的启动器来源链——`fixed`(随系统内置)、`app`(已知目录的 bundle 拼写)、`xcode`(`xcode-select -p`)、`cli`(进程内 PATH 解析)、`file`(`${VAR}`/`~/` 展开后第一个存在的候选文件)、`scan`(带版本号安装目录取最新)、`app-paths` 与 `install-record`(Windows 注册表两层)、`github-desktop`(版本化可执行文件与随包 CLI)、`desktop`(Linux XDG desktop 条目)——加启动器 argv 模板(`{path}` token 原位携带目录,否则目录追加在末尾)、可选环境与 Windows 可见性策略,以及可选回退启动器(`xed` 之后的 `open -a <bundle>`)。白名单对齐 Codex 的 "Open In" 目标列表:编辑器与 IDE(VS Code、VS Code Insiders、Cursor、Windsurf、Zed、Sublime Text、Xcode、Android Studio、七个 JetBrains IDE)、转正插件原有的 Git GUI、终端(Terminal、iTerm2、Ghostty、Warp、kitty、Windows Terminal、Git Bash、GNOME Terminal、Konsole)与各平台文件管理器。文件管理器与平台终端使用独立 id(`finder`/`explorer`/`filemanager`),而非一个 id 配平台标签,因为标签是静态浏览器词典条目,且每台主机只会探测到其中一个。文件管理器条目直接经 `dsh-native-command` 的路径打开器启动(`shell-open`,把目录交给 OS shell 并携带完整父环境,而非 detached 的清理环境 spawn),因为 detached 且经过凭据清洗的 `explorer.exe <dir>` 派生进程不能可靠地弹出窗口;打开器自身的 `explorer.exe` 交接可以([Windows 打开解析](../bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md))。封闭 union 以 `assertNever` 收尾。
|
||||
- **所有路由都运行在组合 connection 服务的信任栅栏之后**(`requestRejection`:挫败 DNS rebinding 与跨站调用的 Host/Origin 栅栏,加上浏览器认证),与 API gateway 施加在其 WebSocket upgrade 上的守卫相同;机制的唯一出处是 `src/index.ts` 的模块注释。在该栅栏之上,open 路由在 wire 边界校验请求体:`application/json` 媒体类型(精确 essence,而非子串匹配)、以排空后 413 的方式把 body 限制在 64 KiB、校验字段类型、只接受探测为可用的目录 id,并要求指向现存目录的绝对路径。
|
||||
- **删除了 DSH 版本门禁。** 它存在是因为插件逐版本骑乘一个它不拥有的接口;第一方包与仓库同版本发布,门禁、它的 `sessionStorage`/`localStorage` 信任台账和 argv 遍历版本探测都失去了所指。
|
||||
- **上次选择经 `createSnapshotStore(..., { persist })` 持久化**(`dsh.open-in-app.choice`),替代手写 `localStorage` 访问。新存储没有平台特定的初始选择;用户首次选择前,组件使用主机提供的第一个可用条目。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md
|
||||
2026-08-26-generic-file-upload.md: ddaf92a396ade59d65a81300531be9bce2aeeaba
|
||||
2026-08-26-generic-file-upload.zh.md: 27a73536ec3632f40ee49a59d932406e77b5591d
|
||||
2026-08-26-generic-file-upload.md: 196d048da9ffc1342cd62cb9c25674b7cecaffac
|
||||
2026-08-26-generic-file-upload.zh.md: fce7cef76cf36060f55165d009c568480abb0f16
|
||||
@@ -52,3 +52,5 @@ Unit and integration coverage pins verbatim and streamed storage, name sanitizat
|
||||
## Related
|
||||
|
||||
The [Trajectory attachment presentation decision](../bug-fix/2026-09-15-trajectory-attachment-presentation.md) owns ledger counts and inspector attachment layouts.
|
||||
|
||||
The [input echo admission decision](../bug-fix/2026-09-22-input-echo-admission-ownership.md) owns placement-specific retirement and delayed Inbox suppression for submissions with or without attachments.
|
||||
@@ -52,3 +52,5 @@ Status: implemented
|
||||
## 相关记录
|
||||
|
||||
[Trajectory 附件展示决定](../bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md)负责列表计数与详情面板附件布局。
|
||||
|
||||
[输入回显入档决策](../bug-fix/2026-09-22-input-echo-admission-ownership.zh.md)负责有无附件提交的按展示归属回收和迟到 Inbox 排除。
|
||||
@@ -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-09-14-composer-selection-keyboard.md
|
||||
2026-09-14-composer-selection-keyboard.md: fb353a7072e7c2245aeeb22af9018edd9a6009f0
|
||||
2026-09-14-composer-selection-keyboard.zh.md: f51e5ca5096785ae6d059e3c8497292483490437
|
||||
2026-09-14-composer-selection-keyboard.md: 675e7239108604c51f4e9a82b98e164833ff9173
|
||||
2026-09-14-composer-selection-keyboard.zh.md: 2b296a609cdd76a407fd4ddcc5855eef2fcfa047
|
||||
@@ -20,6 +20,8 @@ The model seat's dropdown (`ModelSelect`, whose Effort row opens the reasoning-l
|
||||
|
||||
`ModelSelect` keeps `↑`/`↓` as its focus walk over the rows of the shown pane (wrapping; a step taken while the trigger still holds focus enters at the near end). Tab settles: it activates the row the keyboard is on, and with focus still on the trigger it enters the menu at the row of the value in use instead. Escape and Shift+Tab leave a drilled pane first and otherwise close back to the trigger. A pane switch replaces the row that had focus, so each switch names where the keyboard lands: drilling focuses the row of the value in use (its checked row, or the first row when none is marked), and returning to the root pane focuses the cell that opened the pane left. Without that handoff the focus the unmounted row left on the page body sits outside the card's subtree, where its key handling never sees a keystroke.
|
||||
|
||||
Mouse presses on the `ModelSelect` trigger or portaled menu buttons prevent the browser’s default focus transfer: WebKit can otherwise blur the focused row and close the menu before `click`. Native `click` still owns selection and cancellation. Opening the menu or closing it through the trigger puts focus on the trigger. Selection requests return focus there before disabling the option rows, so a rejected request leaves keyboard navigation available.
|
||||
|
||||
`Menu`, the shared anchored dropdown behind the composer's permission seat, the preset chip, file cards, and the settings rows, carries the same pairing: while its list is open, Tab settles the focused row — from the anchor's button, Tab enters the list instead — and Escape or Shift+Tab close it and return focus to the anchor's first button. Only a keyboard already on the anchor or inside the list is intercepted, so Tab presses elsewhere on the page keep the browser's traversal even while a menu is open. The row's focus fill is the row's own indication: the browser's default ring would double it, and the fill is the same one the row shows under the pointer, so the visual language stays one per state. Selecting a row hands the keyboard back to the anchor unless the owner moved it itself — a card that focuses its own preview keeps it. The arrows, Home, and End walk the list whether or not `autoFocus` is set — that option now decides only whether opening focuses the first row — the row the keyboard is on carries the same fill a hovered row gets, and closing on Escape or Shift+Tab returns focus to the anchor whenever the menu held it.
|
||||
|
||||
A dismissed menu stays dismissed: `InputTriggerController` records the identity of the hit the user closed — Escape or Shift+Tab, a pointer dismissal, or a settling pick — and a re-track of that same token with the same query leaves the menu closed, so restoring the caret after a dismissal (or a settled command closing the surface it opened) cannot revive it. A new query or another token re-arms it.
|
||||
@@ -42,7 +44,7 @@ The row contracts are unchanged. Producers already mark their current row `activ
|
||||
|
||||
## Verification
|
||||
|
||||
[Seat browser evidence](../../../../apps/web/tests/declared-reasoning.e2e.ts) drives a real Chromium against the shipped bundles: it opens the drilled level pane, walks it with `↑`, settles a level with Tab and asserts the saved effort, then reopens the pane and asserts the keyboard lands on that level rather than the top of the list before Shift+Tab walks back to the drilled cell and closes. [Seat keyboard tests](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) cover the drilled-pane handoff onto the value in use (and onto the first row when no row is marked), the handoff back onto the cell, the `↑`/`↓` walk with both wraps, Tab settling a focused row and entering the menu from the trigger, Shift+Tab leaving a pane then closing, and Tab staying native while the menu is closed. [Popup view tests](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) cover Tab settling the parked highlight with consumption and refocus, Shift+Tab dismissing without settling, Tab consumed while rows load without leaving the card, the arrows staying distinct from the native left/right caret, and the wrap on `↑`/`↓`. [Trigger tests](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) cover a dismissal sticking to its hit (Escape, a pointer dismissal, and a settling pick), the same hit re-tracking closed, and a changed query re-arming the menu. [Menu tests](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) cover the arrow walk without `autoFocus` (entering at the near end, wrapping, and stepping over a disabled row), Escape returning focus to the anchor, Tab settling the focused row, Shift+Tab closing back to the anchor, Tab entering the list from the anchor, and an unrelated Tab staying native while the list is open. [Popup controller tests](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) cover the parked highlight, its top-row fallback without a marked row, the re-park inside a search retained across a retry, and the rebase to the top row on typing. The popup panel's Tab has unit coverage only: no Web scenario drives `/model` or `/permission` yet.
|
||||
[Seat browser evidence](../../../../apps/web/tests/declared-reasoning.e2e.ts) drives real Chromium and WebKit against the shipped bundles: both verify native mouse selection, cancellation, focus after rejected requests, and keyboard navigation. Each opens the drilled level pane, walks it with `↑`, settles a level with Tab and asserts the saved effort, then reopens the pane and asserts the keyboard lands on that level rather than the top of the list before Shift+Tab walks back to the drilled cell and closes. [Seat keyboard tests](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) cover the drilled-pane handoff onto the value in use (and onto the first row when no row is marked), the handoff back onto the cell, the `↑`/`↓` walk with both wraps, Tab settling a focused row and entering the menu from the trigger, Shift+Tab leaving a pane then closing, and Tab staying native while the menu is closed. [Popup view tests](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) cover Tab settling the parked highlight with consumption and refocus, Shift+Tab dismissing without settling, Tab consumed while rows load without leaving the card, the arrows staying distinct from the native left/right caret, and the wrap on `↑`/`↓`. [Trigger tests](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) cover a dismissal sticking to its hit (Escape, a pointer dismissal, and a settling pick), the same hit re-tracking closed, and a changed query re-arming the menu. [Menu tests](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) cover the arrow walk without `autoFocus` (entering at the near end, wrapping, and stepping over a disabled row), Escape returning focus to the anchor, Tab settling the focused row, Shift+Tab closing back to the anchor, Tab entering the list from the anchor, and an unrelated Tab staying native while the list is open. [Popup controller tests](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) cover the parked highlight, its top-row fallback without a marked row, the re-park inside a search retained across a retry, and the rebase to the top row on typing. The popup panel's Tab has unit coverage only: no Web scenario drives `/model` or `/permission` yet.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ composer 内有两个选择界面在打开期间持有焦点,两者都把 Tab
|
||||
|
||||
`ModelSelect` 的 `↑`/`↓` 仍是在所显示面板行间移动焦点(循环;焦点仍在触发器上时从近端进入)。Tab 执行接受:它激活键盘所在的那一行;焦点仍在触发器上时,改为进入菜单并把焦点放在正在使用的那一行。Escape 与 Shift+Tab 都先退出已下钻的面板,否则关闭并回到触发器。面板切换会替换持有焦点的那一行,因此每次切换都指明键盘的落点:下钻落在正在使用的值那一行(其选中行;没有标记行时为首行),返回根面板则落在打开该面板的那个格子上。没有这一步,已卸载的行留在页面 body 上的焦点就位于卡片自身子树之外,挂在那里的按键处理永远收不到键盘事件。
|
||||
|
||||
在 `ModelSelect` 触发按钮或 portal 菜单按钮上按下鼠标时,阻止浏览器的默认焦点转移:否则 WebKit 可能先让聚焦行失焦,并在 `click` 前关闭菜单。选择和取消仍由原生 `click` 决定。打开菜单或通过触发按钮关闭菜单时,焦点落在触发按钮上。选择请求在禁用选项行前将焦点交回该按钮,因此请求被拒绝后仍可使用键盘导航。
|
||||
|
||||
`Menu`——权限档位、预设芯片、文件卡与设置行背后的共享锚定下拉——沿用同一组配对:列表打开期间,Tab 选定聚焦行(键盘在锚点按钮上时,Tab 改为进入列表),Escape 或 Shift+Tab 关闭并把焦点还给锚点的第一个按钮。只拦截已经位于锚点或列表内的键盘,因此页面其它位置按下的 Tab 即使在菜单打开时也仍归浏览器。行的焦点填充就是行自己的焦点提示:浏览器默认轮廓会与之叠加,而这一填充与指针悬停时用的是同一个,视觉语言因此一态一义。选定一行会把键盘还给锚点——除非拥有者自己移动了焦点(例如文件卡把自己的预览按钮设为焦点)。`↑`/`↓` 与 Home/End 无论是否设置 `autoFocus` 都在列表中走位——该选项如今只决定打开时是否聚焦首行——键盘所在的那一行使用与悬停行相同的填充(否则键盘走位看不出当前行),而 Escape 或 Shift+Tab 关闭时,只要键盘原本在菜单里就把焦点归还锚点。
|
||||
|
||||
被关闭的菜单保持关闭:`InputTriggerController` 记下用户关闭的那个命中(Escape/Shift+Tab、指针关闭或一次落定选定)的身份,同一 token 以同一查询重新 track 时菜单保持关闭,因此关闭后恢复光标、或已选定命令关闭它打开的界面,都无法把它召回来;新的查询或另一个 token 才会重新武装。
|
||||
@@ -42,7 +44,7 @@ composer 内有两个选择界面在打开期间持有焦点,两者都把 Tab
|
||||
|
||||
## 验证
|
||||
|
||||
[模型位浏览器证据](../../../../apps/web/tests/declared-reasoning.e2e.ts) 用真实 Chromium 驱动已发布的 bundle:打开已下钻的等级面板,用 `↑` 走位,用 Tab 选定一档并断言保存下来的推理等级,随后重新打开该面板、断言键盘落在那一档而不是列表顶部,再用 Shift+Tab 退回被下钻的格子并关闭。[模型位键盘测试](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) 覆盖下钻交接落在正在使用的值上(没有标记行时落在首行)、交回给该格子、`↑`/`↓` 的双向循环走位、Tab 选定聚焦行以及从触发器进入菜单、Shift+Tab 先退层再关闭,以及菜单关闭时 Tab 保持原生。[弹窗视图测试](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) 覆盖 Tab 接受停放的高亮并完成消费与焦点归还、Shift+Tab 关闭且不接受、行仍在加载时 Tab 被消费且焦点不离开卡片、方向键与原生左右光标互不干扰,以及 `↑`/`↓` 的循环。[触发流水线测试](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) 覆盖关闭粘在它自己的命中上(Escape、指针关闭、落定选定三种)、同一命中重新 track 时保持关闭,以及查询改变后菜单重新武装。[Menu 测试](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) 覆盖不带 `autoFocus` 的方向键走位(从近端进入、两端循环、跳过禁用行)、Escape 把焦点还给锚点、Tab 选定聚焦行、Shift+Tab 关闭并把焦点还给锚点、Tab 从锚点进入列表,以及列表打开期间无关位置的 Tab 保持原生。[弹窗控制器测试](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) 覆盖高亮的停放、没有标记行时退回首行、重试时在保留的搜索内重新停放,以及输入时重定到首行。弹窗面板的 Tab 只有单元覆盖:目前还没有 Web 场景驱动 `/model` 或 `/permission`。
|
||||
[模型位浏览器证据](../../../../apps/web/tests/declared-reasoning.e2e.ts) 用真实 Chromium 与 WebKit 驱动已发布的 bundle:两者都验证原生鼠标选择、取消、请求被拒绝后的焦点以及键盘导航。每种引擎都会打开已下钻的等级面板,用 `↑` 走位,用 Tab 选定一档并断言保存下来的推理等级,随后重新打开该面板、断言键盘落在那一档而不是列表顶部,再用 Shift+Tab 退回被下钻的格子并关闭。[模型位键盘测试](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) 覆盖下钻交接落在正在使用的值上(没有标记行时落在首行)、交回给该格子、`↑`/`↓` 的双向循环走位、Tab 选定聚焦行以及从触发器进入菜单、Shift+Tab 先退层再关闭,以及菜单关闭时 Tab 保持原生。[弹窗视图测试](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) 覆盖 Tab 接受停放的高亮并完成消费与焦点归还、Shift+Tab 关闭且不接受、行仍在加载时 Tab 被消费且焦点不离开卡片、方向键与原生左右光标互不干扰,以及 `↑`/`↓` 的循环。[触发流水线测试](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) 覆盖关闭粘在它自己的命中上(Escape、指针关闭、落定选定三种)、同一命中重新 track 时保持关闭,以及查询改变后菜单重新武装。[Menu 测试](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) 覆盖不带 `autoFocus` 的方向键走位(从近端进入、两端循环、跳过禁用行)、Escape 把焦点还给锚点、Tab 选定聚焦行、Shift+Tab 关闭并把焦点还给锚点、Tab 从锚点进入列表,以及列表打开期间无关位置的 Tab 保持原生。[弹窗控制器测试](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) 覆盖高亮的停放、没有标记行时退回首行、重试时在保留的搜索内重新停放,以及输入时重定到首行。弹窗面板的 Tab 只有单元覆盖:目前还没有 Web 场景驱动 `/model` 或 `/permission`。
|
||||
|
||||
## 影响
|
||||
|
||||
|
||||
@@ -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-09-16-browser-excel-preview.md
|
||||
2026-09-16-browser-excel-preview.md: 15db888330b2a1a6d6d78dad46689962466d531d
|
||||
2026-09-16-browser-excel-preview.zh.md: 6b3a6a617a0cd18dcd3da1682a31f26bd7e70386
|
||||
2026-09-16-browser-excel-preview.md: 8a4bbd5dc838bc79e61f9b412af43b912a8fe019
|
||||
2026-09-16-browser-excel-preview.zh.md: f04170b28fe4c6d267b12958f47a392638bfaadb
|
||||
@@ -24,7 +24,7 @@ The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@
|
||||
|
||||
The read-only formula bar takes formulas from raw `cell.f` and writes formulas and cell text through `textContent`. FortuneSheet's formula formatter emits HTML spans containing document text, and its HTML helper passes formulas and strings starting with `<span` through unchanged. Neither read-only mode nor the parser Worker isolates this DOM. The [core patch](../../../../patches/@[email protected]) HTML-escapes copied cell contents, including literal strings and saved formula results, while preserving the clipboard table and formatting.
|
||||
|
||||
The ESM and CommonJS entries receive the same patches. Recheck worksheet switching, complete initial selections, literal formula-bar text, and escaped table copying when upgrading FortuneSheet.
|
||||
The core patch also skips drawing freeze dividers when `allowEdit === false`, retaining the frozen-pane calculations and editable-mode dividers. These dividers are Canvas strokes, so scoped CSS cannot change them. Preview-scoped CSS hides the row and column freeze drag handles. The ESM and CommonJS entries receive the same patches. Recheck worksheet switching, complete initial selections, literal formula-bar text, escaped table copying, and frozen-pane rendering and scrolling when upgrading FortuneSheet.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。
|
||||
|
||||
只读公式栏直接读取原始 `cell.f`,通过 `textContent` 写入公式和单元格文本。FortuneSheet 的公式格式化器会生成含有文档文本的 HTML span,其 HTML 辅助函数会原样放行公式和以 `<span` 开头的字符串。只读模式和解析 Worker 都不能隔离这部分 DOM。[core 补丁](../../../../patches/@[email protected])会对复制的单元格内容进行 HTML 转义,包括字面字符串和已保存的公式结果,同时保留剪贴板表格及其格式。
|
||||
|
||||
ESM 和 CommonJS 入口应用相同补丁。升级 FortuneSheet 时需要重新检查工作表切换、完整初始选区、公式栏字面文本以及经过转义的表格复制。
|
||||
core 补丁还在 `allowEdit === false` 时跳过冻结分隔线的绘制,保留冻结窗格计算和编辑模式下的分隔线。这些分隔线由 Canvas 绘制,因此局部 CSS 无法修改。预览区域内的 CSS 隐藏行列冻结拖拽条。ESM 和 CommonJS 入口应用相同补丁。升级 FortuneSheet 时需要重新检查工作表切换、完整初始选区、公式栏字面文本、经过转义的表格复制,以及冻结窗格的绘制和滚动。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-22-unbounded-completion-wakes-by-default.md
|
||||
2026-09-22-unbounded-completion-wakes-by-default.md: 8760154054de423240e16d93e3db5ae01521820a
|
||||
2026-09-22-unbounded-completion-wakes-by-default.zh.md: e5560bc7ced5a8ca9ab55ea56ba309676d78b568
|
||||
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Background job completion wakes are unbounded by default
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-22-unbounded-completion-wakes-by-default.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [idle-owner wake decision](2026-08-11-background-job-completion-wakes-an-idle-owner.md) shipped `maxConsecutiveWakes` with a default of 3: an idle owner could be woken three times by job completions before further notices degraded to injection, and only a claimed user message refilled the budget. The counter was per owner and shared by every job kind — background `bash`/`pwsh`/PTY commands and one-shot `subagent_*` children.
|
||||
|
||||
A session that chained more than three of them without a user message hit the cap (issue #4944, reported as [dsh-external/issues#641](https://github.com/dsh-external/issues/issues/641)). The fourth notice was injected into the next-step inbox of an owner nothing would wake; the client renders no pending `context` message, so the session sat with a notice it could not see. The `tool-jobs` prompt promises the model "you are notified in-session when a job finishes — do not busy-poll", which encourages exactly the start-and-end-turn pattern that exhausts the cap. The stall arrived when the user was away and looked like a hang until they typed something.
|
||||
|
||||
## Decision
|
||||
|
||||
`maxConsecutiveWakes` has no default. Unset, every completion reaching an idle owner under `wakeup` delivery opens a turn. The `agent/inbox/claimed` refill listener and the per-owner ledger exist only when a cap is configured; a configured value must still be a whole number of turns, and `Infinity` is rejected because omitting the field already means unbounded.
|
||||
|
||||
The self-exciting chain the cap was built for — a woken turn starting the job whose completion wakes it again — is still real, and deployments that want the bound set the field. The trade-off is that the chain spends model requests the user can watch and stop, while a notice parked past a cap is invisible: nothing in the client shows it, nothing re-arms the budget, and the model's promise becomes false. An observable cost beats an unobservable stall.
|
||||
|
||||
`dsh run` is unaffected: headless exits once the task turn goes idle, so there is no idle owner left to wake.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the default cap and make the parked notice visible** — a session-level pending indicator or an unread mark on the jobs list. This keeps the runaway bound but still breaks the prompt's promise: the model ended its turn expecting a notice and receives nothing until the user acts. Visibility is worth adding for a configured cap; it does not justify a default one.
|
||||
|
||||
**Count only self-excited wakes** — a wake counts against the budget only when the woken turn starts a new background job. This bounds the runaway chain without cutting off a chain of independent jobs, but the distinction needs the plugin to correlate job starts with the turn a wake opened, state no current consumer needs. It remains the natural refinement if a configured cap proves too coarse.
|
||||
|
||||
**A cooldown or rate limit instead of a counter.** Time does not distinguish a wanted chain from a runaway one; the wanted case is often the slow one, and a rate limit still parks a notice invisibly when it triggers.
|
||||
|
||||
**Re-check the next-step inbox on every turn-close path** (abort, LLM error, rejected pre-step). This closes the other parking spots the issue lists, but they are reached only by an already-parked notice; with no default cap the reported scenario never parks one. It stays a separate `agent-loop` change.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An unattended session chaining background jobs is woken for every completion; the model request per wake is the cost, and the user can stop the session.
|
||||
- The cap is opt-in configuration. Its README, config-catalog, and JSDoc state the silent-stall cost of setting it.
|
||||
- Unit coverage pins four unattended completions each opening a turn under the default config; the cap, refill, and quiet-delivery tests keep explicit `maxConsecutiveWakes` values.
|
||||
- No recorded-session snapshot changes: no shipped profile sets the field, and no existing scenario chains more than three idle completions.
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Background job completion wakes are unbounded by default
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-22-unbounded-completion-wakes-by-default.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[空闲所有者唤醒决策](2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md)发布时 `maxConsecutiveWakes` 默认为 3:空闲所有者最多被任务完成唤醒三次,此后的通知降级为注入,只有被领取的用户消息才补充预算。计数器按所有者记录,并由所有任务类型共用——后台 `bash`/`pwsh`/PTY 命令与一次性 `subagent_*` 子级。
|
||||
|
||||
一个会话在没有用户消息的情况下串起超过三个这样的任务就会触顶(issue #4944,来源为 [dsh-external/issues#641](https://github.com/dsh-external/issues/issues/641))。第四条通知被注入到一个无人会唤醒的所有者的 next-step inbox;客户端不渲染待领的 `context` 消息,会话就带着一条自己看不见的通知停在那里。`tool-jobs` 的提示词向模型承诺「任务完成会在会话内通知你,不要轮询」,恰恰鼓励了会耗尽上限的「启动后结束本轮」模式。停摆发生在用户离开时,看起来像挂死,直到用户输入点什么。
|
||||
|
||||
## 决策
|
||||
|
||||
`maxConsecutiveWakes` 没有默认值。未设置时,`wakeup` 投递下抵达空闲所有者的每次完成都会开启一轮。`agent/inbox/claimed` 补充监听与按所有者的台账只在配置了上限时存在;配置的值仍必须是整数轮数,`Infinity` 被拒绝,因为省略该字段已经表示无上限。
|
||||
|
||||
上限所针对的自激链——被唤醒的一轮启动某个任务,其完成又唤醒它——依然真实存在,想要该界的部署自行设置字段。取舍在于:这条链花掉的是用户看得见、能停下的模型请求,而停在上限之外的通知是不可见的:客户端不显示,没有机制重新充能预算,模型得到的承诺也成了假话。可观察的代价胜过不可观察的停摆。
|
||||
|
||||
`dsh run` 不受影响:headless 在任务轮次空闲后即退出,没有可唤醒的空闲所有者。
|
||||
|
||||
## 被否决的替代方案
|
||||
|
||||
**保留默认上限,让停驻的通知可见**——会话级的待处理指示或任务列表上的未读标记。这保住了失控约束,但仍然违背提示词的承诺:模型结束本轮等着通知,却要等到用户动手才收到。可见性值得为已配置的上限补上;它不能为默认上限辩护。
|
||||
|
||||
**只统计自激的唤醒**——只有被唤醒的一轮又启动了新的后台任务时才计入预算。这约束了失控链而不切断一串独立任务,但这种区分需要插件把任务启动与唤醒所开启的轮次关联起来,是当前没有消费者需要的状态。若配置的上限被证明过粗,它仍是自然的细化方向。
|
||||
|
||||
**用冷却或速率限制代替计数器。** 时间无法区分想要的链与失控的链;想要的情形往往正是慢的那种,而速率限制触发时同样会把通知不可见地停驻。
|
||||
|
||||
**在每条轮次结束路径上复查 next-step inbox**(abort、LLM 错误、被拒绝的 pre-step)。这关闭了 issue 列出的其他停驻点,但它们只会被一条已停驻的通知触及;没有默认上限时,报告的场景根本不会停驻通知。它仍是独立的 `agent-loop` 改动。
|
||||
|
||||
## 影响
|
||||
|
||||
- 串起后台任务的无人值守会话会为每次完成被唤醒;每次唤醒一次模型请求是其代价,用户可以停止会话。
|
||||
- 上限是可选配置。其 README、config-catalog 与 JSDoc 说明了设置它带来的静默停摆代价。
|
||||
- 单元覆盖钉住默认配置下连续四次无人值守完成各开启一轮;上限、补充与 quiet 投递的测试保留显式的 `maxConsecutiveWakes` 值。
|
||||
- 录制会话快照无变化:没有任何内置 profile 设置该字段,也没有既有场景串起超过三次空闲完成。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
|
||||
2026-07-26-ci-failover-runbook.md: c2fd2fa6cf673ec4d202702c16a9a85a3a609b8d
|
||||
2026-07-26-ci-failover-runbook.zh.md: 7fa5887bb552afd98cebd7502202fb5e9f37b1ee
|
||||
2026-07-26-ci-failover-runbook.md: d46f9d140860580011e06aa3b51bb8618a23a114
|
||||
2026-07-26-ci-failover-runbook.zh.md: a229d46f16d959356e9108842edd557420e699ae
|
||||
@@ -20,7 +20,7 @@ The [superseded-CI cancellation policy](2026-09-09-cancel-superseded-ci.md) gove
|
||||
|
||||
### What the in-house pool is
|
||||
|
||||
`vm-backup`: one shared VM with multiple always-on systemd-managed runner instances. Registrations share its CPU, memory, and disk; their count is not a count of independent machines. Its image must preinstall Playwright Chromium's Linux system packages; CI downloads the lockfile-selected browser but never runs `apt` on this persistent shared host. Check the latest `serial / linux (self-hosted standby)` run before switching: its aggregate includes browser replay, so a green standby verifies both ordinary capacity and this browser prerequisite.
|
||||
`vm-backup`: one shared VM with multiple always-on systemd-managed runner instances. Registrations share its CPU, memory, and disk; their count is not a count of independent machines. Its image must preinstall the Linux system packages for Playwright Chromium and WebKit; CI downloads the lockfile-selected browsers but never runs `apt` on this persistent shared host. Check the latest `serial / linux (self-hosted standby)` run before switching: its aggregate includes browser replay, so a green standby verifies both ordinary capacity and this browser prerequisite.
|
||||
|
||||
#### Windows pool
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Status: implemented
|
||||
|
||||
### 自有池是什么
|
||||
|
||||
`vm-backup`:一台共享虚拟机,运行多个常驻 systemd 管理的运行器实例。注册实例共享 CPU、内存和磁盘;实例数量不代表独立机器数量。其镜像必须预装 Playwright Chromium 的 Linux 系统软件包;CI 会下载锁文件选定的浏览器,但绝不在这台持久化共享主机上运行 `apt`。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:其聚合流程包含浏览器回放,因此绿色热备同时验证常规容量和这项浏览器先决条件。
|
||||
`vm-backup`:一台共享虚拟机,运行多个常驻 systemd 管理的运行器实例。注册实例共享 CPU、内存和磁盘;实例数量不代表独立机器数量。其镜像必须预装 Playwright Chromium 和 WebKit 所需的 Linux 系统软件包;CI 会下载锁文件选定的浏览器,但绝不在这台持久化共享主机上运行 `apt`。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:其聚合流程包含浏览器回放,因此绿色热备同时验证常规容量和这项浏览器先决条件。
|
||||
|
||||
#### Windows 池
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md
|
||||
2026-08-10-npm-release-sequences.md: 6f52c00a31870939981994965b70504e8f8143ca
|
||||
2026-08-10-npm-release-sequences.zh.md: effa5538cefbc8f25b2e047ee40e0dc7306bb2fb
|
||||
2026-08-10-npm-release-sequences.md: 6a4f9d1ec23d1fd1c7b74630e106974b680fe41a
|
||||
2026-08-10-npm-release-sequences.zh.md: d33d48e4458a62951aa957e694dd70eedcf2a6c8
|
||||
@@ -78,9 +78,9 @@ Two registry behaviours shape how a publish is attempted. Writes are spaced by a
|
||||
|
||||
### Workspace-internal references use the `workspace:` protocol
|
||||
|
||||
Every reference to a workspace member uses `workspace:^`, so `pnpm pack` substitutes a range matching the target version: sibling `peerDependencies` follow the family version, and a reference to a vendored package follows that package's own line. The Landlock platform packages keep `workspace:*`, which publishes the exact version, because a platform package and its entry must agree exactly.
|
||||
Every reference to a workspace member uses the `workspace:` protocol. The [release-range policy](2026-09-22-workspace-release-ranges.md) requires exact `workspace:*` DSH references and `workspace:~` vendor/native references in every dependency section and consumer, including the native entry's optional platform packages. Local workspace linking is unchanged.
|
||||
|
||||
`scripts/check-workspace-constraints.ts` requires the protocol, so a new package cannot reintroduce a hand-written range; the invariant-companion rule requires `workspace:^` for `@deepseek-ai/dsh-invariants` for the same reason.
|
||||
`scripts/check-workspace-constraints.ts` reads every member declared in `pnpm-workspace.yaml` plus the root manifest and enforces ranges by dependency target, not consumer directory. The invariant-companion rule requires `workspace:*` for `@deepseek-ai/dsh-invariants`; the dependency repairer preserves vendor/native tilde ranges. Published DSH peers therefore require the matching release instead of admitting later compatible versions.
|
||||
|
||||
### Published dependency faces use an explicit policy
|
||||
|
||||
@@ -133,7 +133,7 @@ The installed-consumer probe captures npm's HTTP diagnostics and includes them w
|
||||
|---|---|
|
||||
| release-set manifests | `private: true` removed; `publishConfig.access` per sequence and `repository` with each package's `directory` added |
|
||||
| release-set boundary | every member of `packages/*/*`, `apps/*`, and `vendor/*` |
|
||||
| dependency protocol | workspace-internal references are `workspace:^`, with `check-workspace-constraints.ts` and the invariant-companion rule requiring it |
|
||||
| dependency protocol | every workspace consumer uses `workspace:*` for DSH targets and `workspace:~` for vendor/native targets |
|
||||
| root `AGENTS.md` | the convention that vendored packages are `private: true` no longer holds |
|
||||
| `vendor/README.md` | records `src` joining `cordis`'s `files` as a local modification |
|
||||
| the three native packages | `publishConfig.access: public`, and their workflow passes no `--access` |
|
||||
|
||||
@@ -78,9 +78,9 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间
|
||||
|
||||
### workspace 内部引用走 `workspace:` 协议
|
||||
|
||||
所有指向 workspace 成员的引用都用 `workspace:^`,由 `pnpm pack` 替换成匹配目标版本的范围:兄弟包的 `peerDependencies` 跟随族版本,指向 vendored 包的引用跟随那个包自己的版本线。Landlock 平台包保留 `workspace:*`(发布成精确版本),因为平台包与它的入口必须版本完全一致。
|
||||
所有指向 workspace 成员的引用都用 `workspace:` 协议。[发布范围策略](2026-09-22-workspace-release-ranges.zh.md)要求每个消费者的所有依赖区段对 DSH 使用精确的 `workspace:*` 引用,对 vendor/native 使用 `workspace:~` 引用,包括原生入口的可选平台包。本地 workspace 链接方式不变。
|
||||
|
||||
`scripts/check-workspace-constraints.ts` 要求这个协议,所以新包无法再引入硬写的范围;同理,invariant companion 规则要求 `@deepseek-ai/dsh-invariants` 用 `workspace:^`。
|
||||
`scripts/check-workspace-constraints.ts` 读取 `pnpm-workspace.yaml` 声明的全部成员及根清单,按依赖目标而非消费者目录校验范围。Invariant companion 规则要求 `@deepseek-ai/dsh-invariants` 使用 `workspace:*`;依赖修复器保留 vendor/native tilde 范围。因此,发布的 DSH peer 要求匹配的发布版本,而不接纳后续兼容版本。
|
||||
|
||||
### 发布依赖门面使用显式策略
|
||||
|
||||
@@ -133,7 +133,7 @@ dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 ven
|
||||
|---|---|
|
||||
| 发布集 manifest | 去掉 `private: true`;按序列补 `publishConfig.access` 与带各自 `directory` 的 `repository` |
|
||||
| 发布集边界 | `packages/*/*`、`apps/*`、`vendor/*` 的全部成员 |
|
||||
| 依赖协议 | workspace 内部引用为 `workspace:^`,由 `check-workspace-constraints.ts` 与 invariant companion 规则强制 |
|
||||
| 依赖协议 | 每个 workspace 消费者对 DSH 目标使用 `workspace:*`,对 vendor/native 目标使用 `workspace:~` |
|
||||
| 根 `AGENTS.md` | 「vendored 包是 `private: true`」这条约定不再成立 |
|
||||
| `vendor/README.md` | 记录「`src` 加入 `cordis` 的 `files`」这条本地修改 |
|
||||
| native 三包 | `publishConfig.access: public`,且其 workflow 不传 `--access` |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-26-published-dependency-faces.md
|
||||
2026-08-26-published-dependency-faces.md: 1a94ccedcf6f614c7853c96c329e88d037167460
|
||||
2026-08-26-published-dependency-faces.zh.md: 4ac3f461018c21047af6359ca5e27d050da5a2c4
|
||||
2026-08-26-published-dependency-faces.md: f188ccc0fc91b2899ccb618d3000a9b3cb94eb59
|
||||
2026-08-26-published-dependency-faces.zh.md: d59bb724ceb15f6eae65b509f8437a5dcfa05a23
|
||||
@@ -28,7 +28,7 @@ A workspace package reached by a runtime value import from the Host entry closur
|
||||
|
||||
An export whose constructor identity or module state must be shared appears in `peerRequiredHostExports`; importing one such export keeps the whole package edge in matching `peerDependencies` and `devDependencies`. Each export-table key is an exact module specifier and each value is a reviewed export set. The verifier follows runtime local imports from the Host entry, records named and default imports and re-exports, and rejects exports covered by neither the package list nor an export table; namespace, dynamic, and side-effect imports remain unbounded unless the complete exact entry is package-classified.
|
||||
|
||||
Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, and existing metadata-only peers belong only in `devDependencies`. Host runtime imports, including additional Node entries, follow the Host classification. [Browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) governs ordinary third-party declarations; it partially supersedes their preservation in this decision. Workspace references use `workspace:^`.
|
||||
Workspace imports used by the Client bundle, type-only imports, module augmentations, `dsh.client.inject`, and existing metadata-only peers belong only in `devDependencies`. Host runtime imports, including additional Node entries, follow the Host classification. [Browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) governs ordinary third-party declarations; it partially supersedes their preservation in this decision. [Release ranges](2026-09-22-workspace-release-ranges.md) require `workspace:*` for DSH and `workspace:~` for vendor/native references.
|
||||
|
||||
Some development relationships exist only in `dsh.client.inject` or TypeScript project references. The policy's `configurationOnlyDevDependencies` table names only those reviewed edges and keeps them in `devDependencies`.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Host 入口闭包中的运行期 value import 所到达的 workspace 包,只
|
||||
|
||||
constructor 身份或模块状态必须共享的导出列入 `peerRequiredHostExports`;一旦使用这类导出,整条包依赖边就保留在范围一致的 `peerDependencies` 与 `devDependencies` 中。每个导出表的 key 都是精确 module specifier,每个 value 都是经审查的导出集合。验证器从 Host 入口沿运行期本地 import 扫描,记录具名与默认 import 和 re-export,并拒绝既没有包级分类、也没有导出级分类的导出;除非完整的精确入口已按包分类,否则 namespace、dynamic 和 side-effect import 仍无法限定范围。
|
||||
|
||||
Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject` 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时 import(包括额外 Node 入口)遵循 Host 分类。[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)规定普通第三方声明,部分取代本决策对它们原区段的保留。Workspace 引用使用 `workspace:^`。
|
||||
Client bundle 使用的 workspace import、纯类型 import、模块扩充、`dsh.client.inject` 和仅有元数据的现存 peer 只属于 `devDependencies`。Host 运行时 import(包括额外 Node 入口)遵循 Host 分类。[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)规定普通第三方声明,部分取代本决策对它们原区段的保留。[发布范围](2026-09-22-workspace-release-ranges.zh.md)要求 DSH 引用使用 `workspace:*`,vendor/native 引用使用 `workspace:~`。
|
||||
|
||||
部分开发期关系只存在于 `dsh.client.inject` 或 TypeScript project reference 中。策略的 `configurationOnlyDevDependencies` 表只列出这些已评审的依赖边,并将它们保留在 `devDependencies` 中。
|
||||
|
||||
|
||||
@@ -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/process/2026-09-22-workspace-release-ranges.md
|
||||
2026-09-22-workspace-release-ranges.md: 57c74df3eaeac16ba40f55cdc4069762ae8fe2ef
|
||||
2026-09-22-workspace-release-ranges.zh.md: f39ede4fb1941956af12d0391f5b88bd244db6f8
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Workspace dependency release ranges
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-22-workspace-release-ranges.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
DSH packages share one product release. Cordis, its vendored libraries, and Node Addon System have independent releases; consumers need their patch updates without automatically accepting a new minor version.
|
||||
|
||||
## Decision
|
||||
|
||||
Every workspace consumer uses `workspace:*` for DSH targets and `workspace:~` for targets under `vendor/` or in the `native/system` package family. This includes root tooling, apps, peers, development dependencies, and the native entry's optional platform dependencies. Directory placement never exempts a consumer.
|
||||
|
||||
Packing substitutes the target package's current version: `workspace:~` becomes `~4.0.3` for Cordis at `4.0.3` and `~0.1.2` for Node Addon System at `0.1.2`. DSH references remain exact. Native family members still share one release version, while an installed entry may resolve a later platform patch.
|
||||
|
||||
This range policy refines [npm release sequences](2026-08-10-npm-release-sequences.md) and [published dependency faces](2026-08-26-published-dependency-faces.md). Their independent release families and dependency-section classification remain unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Caret vendor ranges.** For stable packages such as Cordis, caret ranges also admit later minor versions.
|
||||
|
||||
**Exact vendor and native ranges.** Exact ranges require a consumer declaration change to admit each patch release.
|
||||
|
||||
## Consequences
|
||||
|
||||
Vendor and native patch releases must preserve their consumer-facing APIs and binary interfaces. Local workspace linking and lockfile resolutions remain unchanged by the range conversion. The workspace gate rejects DSH tilde ranges and vendor/native caret or exact ranges; packed-manifest tests check the emitted versions.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Workspace 依赖发布范围
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-22-workspace-release-ranges.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
DSH 包共享同一次产品发布。Cordis、其 vendored 库和 Node Addon System 独立发布;消费者需要接收它们的补丁更新,而不自动接纳新的次版本。
|
||||
|
||||
## Decision
|
||||
|
||||
每个 workspace 消费者对 DSH 目标使用 `workspace:*`,对 `vendor/` 下或 `native/system` 包族中的目标使用 `workspace:~`。规则覆盖根目录工具、应用、peer、开发依赖以及原生入口的可选平台依赖。消费者不因目录位置而获得豁免。
|
||||
|
||||
打包时替换为目标包的当前版本:Cordis 为 `4.0.3` 时,`workspace:~` 转为 `~4.0.3`;Node Addon System 为 `0.1.2` 时转为 `~0.1.2`。DSH 引用仍为精确版本。原生包族的成员仍以同一版本发布,但已安装的入口可以解析到后续的平台包补丁版本。
|
||||
|
||||
这套范围策略细化了 [npm 发布序列](2026-08-10-npm-release-sequences.zh.md)和[发布依赖门面](2026-08-26-published-dependency-faces.zh.md)。它们的独立发布族和依赖区段分类保持不变。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Vendor 使用 caret 范围。** 对 Cordis 这类稳定版本的包,caret 范围也会接纳后续次版本。
|
||||
|
||||
**Vendor 和 native 使用精确范围。** 每次接纳补丁发布都需要修改消费者的依赖声明。
|
||||
|
||||
## Consequences
|
||||
|
||||
Vendor 和 native 的补丁发布必须保持面向消费者的 API 和二进制接口兼容。范围转换不改变本地 workspace 链接及锁文件解析结果。Workspace 门禁拒绝 DSH 的 tilde 范围,以及 vendor/native 的 caret 或精确范围;打包清单测试校验最终输出的版本。
|
||||
+2
-2
@@ -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/simplification/2026-09-09-nontransactional-loader.md
|
||||
2026-09-09-nontransactional-loader.md: 6a3cb1476e1f296199067a8d048c6d7c8bba4069
|
||||
2026-09-09-nontransactional-loader.zh.md: 4e91a31ed86be4e45313e1322db8af486477aea7
|
||||
2026-09-09-nontransactional-loader.md: 98b0b1ce0c1250e651c5280c9b918948b17cd963
|
||||
2026-09-09-nontransactional-loader.zh.md: 77ae1ce91aa0d20940f0c63f2a2d42a0f3c07edb
|
||||
@@ -18,7 +18,7 @@ Application consumers own their completion checks. The CLI waits for its fallbac
|
||||
|
||||
Fiber, Entry, and isolate keep their upstream update return behavior. App boot observes discarded restart promises through the existing `internal/update` waterfall and waits for fibers before auditing a patch reload. The detached import-completion observer handles both fiber outcomes; the fiber still retains its failure for an explicit audit. Durable Include writes drain before and after child removal so a later teardown write cannot erase an earlier terminal write failure.
|
||||
|
||||
Two #932-specific vendor changes remain: awaited initial-file creation and forced rereading in Include, and Schemastery conditional exports. Restoring the pre-#932 debounced write/read sequence reproduces `ENOENT` in the missing-file initialization test. Keeping these two lines preserves the existing `initial` option without an application-side file writer or a second YAML serializer. Removing Schemastery exports reproduces `ERR_REQUIRE_ESM_RACE_CONDITION` while the Web preset suite boots: Node falls back to the CJS entry during concurrent ESM imports. The HMR injection decorators, conditional patch cloning, and update return values use the pre-#932 behavior. Explicit `workspace:^` dependencies make the #932 workspace-link switch and dedicated lockfile check unnecessary.
|
||||
Two #932-specific vendor changes remain: awaited initial-file creation and forced rereading in Include, and Schemastery conditional exports. Restoring the pre-#932 debounced write/read sequence reproduces `ENOENT` in the missing-file initialization test. Keeping these two lines preserves the existing `initial` option without an application-side file writer or a second YAML serializer. Removing Schemastery exports reproduces `ERR_REQUIRE_ESM_RACE_CONDITION` while the Web preset suite boots: Node falls back to the CJS entry during concurrent ESM imports. The HMR injection decorators, conditional patch cloning, and update return values use the pre-#932 behavior. Explicit `workspace:` dependencies make the #932 workspace-link switch and dedicated lockfile check unnecessary.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Loader 立即更改条目选项。EntryGroup 并发启动同级条目并记录
|
||||
|
||||
Fiber、Entry 和 isolate 保持上游的更新返回行为。App boot 通过现有的 `internal/update` waterfall 观察被丢弃的重启 promise,并在检查 patch 重载前等待 fiber。游离的导入完成观察器处理 fiber 的两种结果;fiber 仍保留失败信息供显式检查。Include 的持久写入在删除子条目前后均排空,防止后续拆卸写入掩盖更早的终止性写入失败。
|
||||
|
||||
保留两项 #932 专属 vendor 改动:Include 中等待初始文件创建并强制重新读取,以及 Schemastery 条件导出。恢复 #932 前的防抖写入和读取顺序,会在缺失文件初始化测试中复现 `ENOENT`。保留这两行可以维持已有 `initial` 选项,而无需在应用侧增加文件写入器或另一份 YAML 序列化逻辑。移除 Schemastery exports 后,Web preset 测试在启动时复现 `ERR_REQUIRE_ESM_RACE_CONDITION`:并发 ESM 导入使 Node 回退到 CJS 入口。HMR 注入装饰器、条件 patch 克隆及更新返回值采用 #932 前的行为。显式 `workspace:^` 依赖使 #932 的 workspace 链接开关与专用锁文件检查不再必要。
|
||||
保留两项 #932 专属 vendor 改动:Include 中等待初始文件创建并强制重新读取,以及 Schemastery 条件导出。恢复 #932 前的防抖写入和读取顺序,会在缺失文件初始化测试中复现 `ENOENT`。保留这两行可以维持已有 `initial` 选项,而无需在应用侧增加文件写入器或另一份 YAML 序列化逻辑。移除 Schemastery exports 后,Web preset 测试在启动时复现 `ERR_REQUIRE_ESM_RACE_CONDITION`:并发 ESM 导入使 Node 回退到 CJS 入口。HMR 注入装饰器、条件 patch 克隆及更新返回值采用 #932 前的行为。显式 `workspace:` 依赖使 #932 的 workspace 链接开关与专用锁文件检查不再必要。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -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/testing/2026-07-24-web-gui-browser-e2e-lane.md
|
||||
2026-07-24-web-gui-browser-e2e-lane.md: b08d99a7d750bdeabac3dcbb7007e922d32dbdcd
|
||||
2026-07-24-web-gui-browser-e2e-lane.zh.md: fbb92a094bbd14042d33e5ebd20d2d3ffd490d43
|
||||
2026-07-24-web-gui-browser-e2e-lane.md: eb79591e7b94fccb8eb92aec4024738825d214fc
|
||||
2026-07-24-web-gui-browser-e2e-lane.zh.md: f715f4ca93b202f72e632e5ddae0d13559a927a7
|
||||
@@ -10,7 +10,7 @@ The web GUI ships as a real assembled chain — chromium page → client plugin
|
||||
|
||||
## Decision
|
||||
|
||||
`pnpm run test:web` carries a keyless, deterministic browser e2e lane under `apps/web/tests/`: recorded session-log fixtures replay through `@deepseek-ai/dsh-llm-replay` against the real in-process web composition, with normalized aria goldens for user-visible states and in-process assertions for durable world state. The supporting product contracts are `dsh-llm-replay` pacing, consumption checks, and validated indexed override patches; cross-package `dsh-llm` failures retain validated provider facts through own data properties; and the shipped web composition mounts `llm-retry` for transient model failures.
|
||||
`pnpm run test:web` carries a keyless, deterministic browser e2e lane under `apps/web/tests/`: recorded session-log fixtures replay through `@deepseek-ai/dsh-llm-replay` against the real in-process web composition, with normalized aria goldens for user-visible states and in-process assertions for durable world state. Chromium runs the full lane; the [model and reasoning picker scenario](../../../../apps/web/tests/declared-reasoning.e2e.ts) also runs in WebKit to cover native mouse focus behavior. The supporting product contracts are `dsh-llm-replay` pacing, consumption checks, and validated indexed override patches; cross-package `dsh-llm` failures retain validated provider facts through own data properties; and the shipped web composition mounts `llm-retry` for transient model failures.
|
||||
|
||||
### Scaffold: `apps/web/tests/scaffold.ts`
|
||||
|
||||
@@ -32,7 +32,7 @@ No single-shot transient-DOM assertions: every hop from replay yield to React co
|
||||
|
||||
Pagination drivers wait for the interactive load row to leave its pending state and record the pre-request row count before scrolling. An immediately committed resident page therefore remains observable instead of becoming the baseline for a request that the scroll gesture does not repeat.
|
||||
|
||||
Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../../archived/feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home.
|
||||
Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one browser per file and engine, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../../archived/feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home.
|
||||
|
||||
### Expected outputs
|
||||
|
||||
@@ -50,7 +50,7 @@ The lane covers three behavior families. Live-turn scenarios pin ordinary tool e
|
||||
|
||||
### CI stance
|
||||
|
||||
The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The `node 24 / snapshots and artifacts` consumer job owns the [single Linux build](../../archived/process/2026-07-30-independent-ci-consumer-build.md), installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The self-hosted default-branch Linux serial standby runs the same gate; there is no hosted Linux serial producer of the browser cache consumed by pull requests, and the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices.
|
||||
The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The `node 24 / snapshots and artifacts` consumer job owns the [single Linux build](../../archived/process/2026-07-30-independent-ci-consumer-build.md), installs the lockfile-selected Chromium and WebKit, restores their OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The self-hosted default-branch Linux serial standby runs the same gate; there is no hosted Linux serial producer of the browser cache consumed by pull requests, and the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices.
|
||||
|
||||
High-cardinality performance diagnostics use the separate opt-in `apps/web/tests/**/*.perf.ts` inventory selected only by `vitest.web.perf.config.ts`. The isolated `complex-history.perf.ts` cases reuse the real scaffold: the workspace case seeds 1,000 compact sessions plus one 500-turn history containing 500 tool calls, exhausts and remounts that history in Chat, and reports Chromium main-thread, DOM, listener, heap, paging, search, and Trajectory measurements. Two continuation cases seed the same long history but compare the default 24-turn Chat window with all 500 turns expanded before each continues eight identical turns through the real composer, agent loop, SSE wire, tools, and persistence; two turns execute a real `bash` call and assert its durable result, while the final turn fills an 8,232-character mixed-language prompt and replays 120 paced text deltas. A separate soak case starts from a blank session, drives 100 consecutive real composer turns with a `bash` call and result every tenth turn, forces GC every ten turns, and reports ten-turn latency windows plus retained browser state. It then submits a 101st text-only turn with a trusted browser click and measures browser-clock send-to-transcript-DOM and send-to-post-paint latency, excluding the composer's draft mirrors, separately from full-turn completion. Per-turn diagnostics cover composer fill, click-to-user-echo, click-to-first-chunk, completion, browser mutations, persisted chunks, and tool events; the synthetic replay model has enough context capacity to keep fixture cardinality stable instead of consuming scripted calls through compaction. Structural assertions pin the intended load, stream, and tool shapes, but timing remains threshold-free because machine speed is not a correctness contract. The required `vitest.web.config.ts` inventory remains limited to `*.e2e.ts` and `*.snapshot.ts`, so neither `test:web:built` nor its CI gate collects performance cases.
|
||||
|
||||
@@ -95,4 +95,4 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot
|
||||
|
||||
## Consequences
|
||||
|
||||
The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compaction-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the required consumer job pays for Chromium provisioning and one browser run so the PR that changes the assembled UI owns its expected-output diff. The opt-in performance lane preserves a repeatable, threshold-free diagnostic workload whose measurements require manual interpretation. The separate required [frontend performance benchmarks](2026-09-06-frontend-performance-budgets.md) enforce calibrated budgets in the isolated benchmark CI job; they do not add thresholds to the manual inventory.
|
||||
The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compaction-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the required consumer job pays for Chromium and WebKit provisioning and the browser lane so the PR that changes the assembled UI owns its expected-output diff. The opt-in performance lane preserves a repeatable, threshold-free diagnostic workload whose measurements require manual interpretation. The separate required [frontend performance benchmarks](2026-09-06-frontend-performance-budgets.md) enforce calibrated budgets in the isolated benchmark CI job; they do not add thresholds to the manual inventory.
|
||||
@@ -10,9 +10,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
|
||||
|
||||
## 决策
|
||||
|
||||
布局断言先等待字体加载、框架过渡完成及 Conversation 宽度发布,再读取尺寸;触发合成 resize 后,还需等待其调度的 React 更新,才能测量通过 portal 挂载的面板。目录树的子目录和根目录读取使用相同的 Remote 等待预算。工作区重载场景在打开另一个路径编辑器前,等待 Session 选择恢复及输入框获得焦点,因为迟到的聚焦会取消路径草稿。响应式文件标签场景将实测通道宽度放在容器查询档位内部,并为平台字体度量保留明确余量。
|
||||
|
||||
`pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放;用户可见状态使用规范化的 aria 预期输出,持久化的世界状态则使用进程内断言。配套的产品约定包括 `dsh-llm-replay` 的节奏控制、消费检查与已校验的索引式覆写 patch;跨包的 `dsh-llm` 失败通过自有数据属性保留经校验的提供方信息;已交付的 web 组合挂载 `llm-retry`,以处理瞬态模型失败。
|
||||
`pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放;用户可见状态使用规范化的 aria 预期输出,持久化的世界状态则使用进程内断言。Chromium 运行整个车道;[模型与推理强度选择场景](../../../../apps/web/tests/declared-reasoning.e2e.ts) 还在 WebKit 中运行,以覆盖原生鼠标焦点行为。配套的产品约定包括 `dsh-llm-replay` 的节奏控制、消费检查与已校验的索引式覆写 patch;跨包的 `dsh-llm` 失败通过自有数据属性保留经校验的提供方信息;已交付的 web 组合挂载 `llm-retry`,以处理瞬态模型失败。
|
||||
|
||||
### Scaffold:`apps/web/tests/scaffold.ts`
|
||||
|
||||
@@ -28,11 +26,13 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
|
||||
|
||||
回放模式下浏览器断言的屏障栈,按序:(1)host 侧 `await agent.whenIdle()` 加超时,以进程内 `turn/end` 为锚——空闲翻转发生在持久化落盘之后,一次等待同时覆盖轮次完成与持久性;(2)浏览器安定轮询(流式输出节点已卸载、最终文本可见)。录制模式下,日志采收在 `whenIdle()` 之后、scaffold 释放之前进行,此时运行中的会话仍然可用。单独监听进程内 `turn/end` 是错误屏障(它先于 SSE 帧到达浏览器、先于 fsync 触发);禁止轮询持久化文件来充当轮次完成或持久性屏障(NFS 上慢,且被 `whenIdle` 取代),但工具控制的临时就绪标记可以仅作为该完成屏障之前的交互门控进行轮询;`networkidle` 被彻底禁止(SSE 流保持打开时它永不解析)。导航断言会在页面加载前同时监听 `session.list` 和 `workspace.list` 的初始响应,随后等待播种数据投影到 DOM;仅凭 shell 已挂载不能判定就绪,因为较晚完成的 bootstrap 可能替换受控状态。
|
||||
|
||||
布局断言先等待字体加载、框架过渡完成及 Conversation 宽度发布,再读取尺寸;触发合成 resize 后,还需等待其调度的 React 更新,才能测量通过 portal 挂载的面板。目录树的子目录和根目录读取使用相同的 Remote 等待预算。工作区重载场景在打开另一个路径编辑器前,等待 Session 选择恢复及输入框获得焦点,因为迟到的聚焦会取消路径草稿。响应式文件标签场景将实测通道宽度放在容器查询档位内部,并为平台字体度量保留明确余量。
|
||||
|
||||
不做单次瞬态 DOM 断言:从 replay yield 到 React commit 的每一跳都可能合并 chunk,采样 `[data-streaming]` 天然就是竞态。流式增量性通过有序 `agent/assistant-stream` follow path 断言,最终持久 `assistant/message` 或 `assistant/attempt` 则嵌入 replay 使用的精确 stream。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = burst)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且 pace wait 期间 abort 会即时取消。
|
||||
|
||||
分页驱动会等待交互式加载行退出 pending 状态,并在滚动前记录请求前的行数。因此,即时提交的常驻页仍然可观测,不会变成一次滚动手势不会重复触发的请求基线。
|
||||
|
||||
每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../../archived/feature/2026-07-31-browser-derived-initial-locale.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。
|
||||
每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每个文件中的每种引擎各启动一个浏览器、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../../archived/feature/2026-07-31-browser-derived-initial-locale.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。
|
||||
|
||||
### 预期输出
|
||||
|
||||
@@ -50,7 +50,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
|
||||
|
||||
### CI 立场
|
||||
|
||||
根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.zh.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../../archived/process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动约定](../architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。自托管的默认分支 Linux 串行热备运行同一门禁;不存在托管 Linux 串行生产者生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。
|
||||
根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.zh.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../../archived/process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium 和 WebKit,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动约定](../architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。自托管的默认分支 Linux 串行热备运行同一门禁;不存在托管 Linux 串行生产者生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。
|
||||
|
||||
高基数性能诊断使用单独按需启用的 `apps/web/tests/**/*.perf.ts` 清单,并且只由 `vitest.web.perf.config.ts` 选中。`complex-history.perf.ts` 的隔离用例复用真实 scaffold:工作区用例播种 1,000 个紧凑会话以及一份包含 500 次工具调用的 500 轮次历史,在 Chat 中穷尽并重新挂载该历史,并报告 Chromium 主线程、DOM、监听器、堆内存、分页、搜索和 Trajectory 测量结果。两个续聊用例播种同一份长历史,但比较默认的 24 轮次 Chat 窗口与展开全部 500 轮次的状态,然后各自通过真实输入框、agent loop、SSE wire、工具和持久化继续进行 8 个相同轮次;其中两轮执行真实 `bash` 调用并断言其持久化结果,最后一轮则填入一条包含 8,232 个字符的混合语言提示词,并回放 120 个带节奏的文本增量。一个单独的 soak 用例从空白会话开始,通过真实输入框连续驱动 100 轮,每第 10 轮执行一次 `bash` 调用并产生结果,每 10 轮强制执行一次 GC,并报告每 10 轮的延迟窗口及保留的浏览器状态。随后它通过受信任的浏览器点击提交第 101 个纯文本轮次,并使用浏览器时钟分别测量发送到 transcript DOM 和发送到绘制后的延迟,排除输入框的草稿镜像,并与完整轮次完成时间分开。逐轮诊断涵盖输入框填入、点击到用户消息回显、点击到首个分片、完成、浏览器变更、持久化分片和工具事件;合成回放模型拥有足够的上下文容量,可使 fixture 基数保持稳定,而不会因压缩(compaction)消耗脚本化调用。结构性断言钉住预期的负载、流和工具形状,但时间仍不设阈值,因为机器速度不属于正确性约定。必需的 `vitest.web.config.ts` 清单仍仅限 `*.e2e.ts` 和 `*.snapshot.ts`,因此 `test:web:built` 及其 CI 门禁都不会收集性能用例。
|
||||
|
||||
@@ -95,4 +95,4 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
|
||||
|
||||
## 后果
|
||||
|
||||
Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compaction-basic` 与会话共享回放游标,仅在目录中发布的 128k 上下文窗口下保持闲置;必需的消费方任务承担 Chromium 供给与一次浏览器运行的成本,使改动组装后 UI 的 PR(Pull Request)持有相应的预期输出 diff。按需启用的性能车道保留可重复、无阈值的诊断工作负载,其测量需要人工解读。独立的必需[前端性能基准](2026-09-06-frontend-performance-budgets.zh.md)在隔离的基准 CI job 中执行经校准的预算;它们不向手动清单添加阈值。
|
||||
Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compaction-basic` 与会话共享回放游标,仅在目录中发布的 128k 上下文窗口下保持闲置;必需的消费方任务承担 Chromium 与 WebKit 供给及浏览器车道的成本,使改动组装后 UI 的 PR(Pull Request)持有相应的预期输出 diff。按需启用的性能车道保留可重复、无阈值的诊断工作负载,其测量需要人工解读。独立的必需[前端性能基准](2026-09-06-frontend-performance-budgets.zh.md)在隔离的基准 CI job 中执行经校准的预算;它们不向手动清单添加阈值。
|
||||
@@ -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/testing/2026-07-30-web-browser-snapshot-ci-gate.md
|
||||
2026-07-30-web-browser-snapshot-ci-gate.md: d1c8dcdd859d0515804cc6aed0d2f3341beb6dd0
|
||||
2026-07-30-web-browser-snapshot-ci-gate.zh.md: 9d642819e09abc63384f4f4eb105d13261a9eb95
|
||||
2026-07-30-web-browser-snapshot-ci-gate.md: 352072bca6e1a9e1d5b72973d2c3c9e96bc58ad9
|
||||
2026-07-30-web-browser-snapshot-ci-gate.zh.md: cd04f33a5db6ddb3c88a235fb34ea31da0e5a869
|
||||
@@ -12,13 +12,13 @@ The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs
|
||||
|
||||
For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. When `DSH_WEB_SNAPSHOT_WORKERS` is configured, `scripts/run-gates.ts` registers `test:web:ci` as the `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing.
|
||||
|
||||
The consumer job owns the [single Linux build](../../archived/process/2026-07-30-independent-ci-consumer-build.md), so `apps/web/dist` and the package `lib/` directories remain in its workspace for the browser suite. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. Pull requests restore the operating-system-and-lockfile-keyed browser cache without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. No master job produces these hosted caches, so restores hit archived entries until they evict. The self-hosted standby runs the same comparison without hosted cache actions.
|
||||
The consumer job owns the [single Linux build](../../archived/process/2026-07-30-independent-ci-consumer-build.md), so `apps/web/dist` and the package `lib/` directories remain in its workspace for the browser suite. Chromium runs the full suite; the model and reasoning picker also runs in WebKit to cover native mouse focus behavior. On hosted runners, CI installs both engines and their system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns both engines’ Linux system packages and CI installs only the browser binaries, avoiding per-run `apt` mutation. Pull requests restore the operating-system-and-lockfile-keyed browser cache without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. No master job produces these hosted caches, so restores hit archived entries until they evict. The self-hosted standby runs the same comparison without hosted cache actions.
|
||||
|
||||
Local `pnpm run test:web` continues to build first and then run the full browser suite serially; `test:web:built` is the serial entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written.
|
||||
|
||||
CI’s `scripts/run-web-snapshots.ts` runs `hmr-live.e2e.ts` separately because it mutates built workspace state. One six-worker Vitest pool then runs every remaining file, including historical Cordis-card rendering. Every child inherits stdio, and `run-gates` streams its output.
|
||||
|
||||
For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The self-hosted default-branch Linux serial standby also includes the comparison, while the macOS and Windows serial jobs remain browser-free (there is no hosted Linux serial aggregate). A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name.
|
||||
For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision both engines. The self-hosted default-branch Linux serial standby also includes the comparison, while the macOS and Windows serial jobs remain browser-free (there is no hosted Linux serial aggregate). A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name.
|
||||
|
||||
Completed local replays measured the six-worker browser command at about 65–71 seconds. A twelve-worker comparison completed in about 50 seconds, so halving the browser worker budget adds about 15–20 seconds rather than doubling wall time. The gate scheduler starts browser snapshots as soon as `built-package-invariants` succeeds and runs independent gates concurrently, so it needs neither a dedicated job timeout nor a manual YAML ordering rule.
|
||||
|
||||
@@ -36,4 +36,4 @@ Completed local replays measured the six-worker browser command at about 65–71
|
||||
|
||||
## Consequences
|
||||
|
||||
Before merge, every PR proves that the current web assembly matches all committed browser expected outputs; a missing refresh fails in the same PR that changes the assembly. The cost is Chromium provisioning, one serial scenario, and one bounded six-worker pool in the consumer job; the consumer-owned build and browser cache avoid duplicate builds and downloads on reruns. Parallel-file failures stream immediately, but a worker-budget change still requires a completed end-to-end measurement rather than an elapsed-time guess. The gate makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn.
|
||||
Before merge, every PR proves that the current web assembly matches all committed browser expected outputs; a missing refresh fails in the same PR that changes the assembly. The cost is Chromium and WebKit provisioning, one serial scenario, and one bounded six-worker pool in the consumer job; the consumer-owned build and browser cache avoid duplicate builds and downloads on reruns. Parallel-file failures stream immediately, but a worker-budget change still requires a completed end-to-end measurement rather than an elapsed-time guess. The gate makes no claim of cross-platform browser consistency, and if a Playwright or browser upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn.
|
||||
@@ -12,13 +12,13 @@ Status: implemented
|
||||
|
||||
Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。配置 `DSH_WEB_SNAPSHOT_WORKERS` 后,`scripts/run-gates.ts` 把 `test:web:ci` 登记为 `ci-consumers` 门禁,并显式注入 `DSH_SNAPSHOT=replay`;CI 永不以 `record` 或 `refresh` 模式运行,因此提交的预期输出与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。
|
||||
|
||||
消费方 job 在[消费方独立构建](../../archived/process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中,供浏览器套件使用。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。PR 恢复以操作系统和锁文件为键的浏览器缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。没有任何 master 作业生成这些 hosted 缓存,因此恢复只能命中仍有归档的旧条目,直至其被逐出。自托管热备运行相同的比较,但不执行托管缓存操作。
|
||||
消费方 job 在[消费方独立构建](../../archived/process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中,供浏览器套件使用。Chromium 运行完整套件;模型与推理强度选择场景还在 WebKit 中运行,以覆盖原生鼠标焦点行为。在托管运行器上,CI 按锁文件中的 Playwright 版本安装这两种引擎及其系统依赖。在持久化故障切换 VM 上,镜像负责预装两种引擎的 Linux 系统软件包,CI 只安装浏览器程序,避免每次运行都通过 `apt` 改动系统。PR 恢复以操作系统和锁文件为键的浏览器缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。没有任何 master 作业生成这些 hosted 缓存,因此恢复只能命中仍有归档的旧条目,直至其被逐出。自托管热备运行相同的比较,但不执行托管缓存操作。
|
||||
|
||||
本地 `pnpm run test:web` 仍先构建,再串行运行完整浏览器套件;`test:web:built` 是已有构建产物的串行执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处预期输出 diff,再以 replay 模式复验不再写文件。
|
||||
|
||||
CI 的 `scripts/run-web-snapshots.ts` 单独运行会修改构建产物的 `hmr-live.e2e.ts`。随后由一个六 worker Vitest 池运行其余文件,包括历史 Cordis 卡片渲染。所有子进程继承 stdio,由 `run-gates` 流式转发输出。
|
||||
|
||||
对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不安装 Chromium。自托管的默认分支 Linux 串行热备也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器(不存在托管的 Linux 串行聚合)。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。
|
||||
对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不会同时安装这两种引擎。自托管的默认分支 Linux 串行热备也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器(不存在托管的 Linux 串行聚合)。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。
|
||||
|
||||
完整本地 replay 中,6-worker 浏览器命令耗时约 65–71 秒。12-worker 对比约为 50 秒,因此把浏览器 worker 预算减半只增加约 15–20 秒,而不是让墙钟时间翻倍。门禁调度器会在 `built-package-invariants` 成功后立即启动浏览器快照,并发运行彼此独立的门禁,因此既不需要专用 job 超时,也不需要手动制定 YAML 顺序规则。
|
||||
|
||||
@@ -36,4 +36,4 @@ CI 的 `scripts/run-web-snapshots.ts` 单独运行会修改构建产物的 `hmr-
|
||||
|
||||
## 后果
|
||||
|
||||
每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器预期输出一致;漏刷会在改变该组装的同一个 PR 中失败。成本是消费方 job 需要安装 Chromium、串行运行 2 个场景并执行 1 个有界 6-worker 池;消费方独立构建与浏览器缓存避免重跑时重复构建和下载。并行文件的失败会立即流式显示,但 worker 预算的任何变化仍需要完整端到端测量,而不能依据运行中耗时猜测。门禁不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 ARIA 格式,升级 PR 必须显式 refresh 并评审 churn。
|
||||
每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器预期输出一致;漏刷会在改变该组装的同一个 PR 中失败。成本是消费方 job 需要安装 Chromium 和 WebKit、串行运行 1 个场景并执行 1 个有界 6-worker 池;消费方独立构建与浏览器缓存避免重跑时重复构建和下载。并行文件的失败会立即流式显示,但 worker 预算的任何变化仍需要完整端到端测量,而不能依据运行中耗时猜测。门禁不声称跨平台浏览器一致性,Playwright 或浏览器升级若改变 ARIA 格式,升级 PR 必须显式 refresh 并评审 churn。
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
name: agent-experience
|
||||
description: Use when designing agent tools, skills, context loading, or multi-step workflows to make information discoverable and use context efficiently.
|
||||
metadata:
|
||||
date: "2026-09-11"
|
||||
---
|
||||
|
||||
- **Start with minimal context:** expose purpose, available actions, and constraints first; load detailed instructions when needed.
|
||||
- **Make discovery explicit:** every deferred resource needs a clear description and a reliable way to retrieve it.
|
||||
- **Keep critical constraints visible:** permissions, destructive effects, and required validation should appear before the relevant action.
|
||||
- **Prefer bounded outputs:** return concise results with identifiers or paths for retrieving details; avoid dumping entire logs or documents.
|
||||
- **Use locality:** include a bounded amount of likely needed adjacent context with an operation’s result. For example, deliver a thread reply with a few preceding messages. Mark omissions and truncation explicitly, and provide a way to retrieve more.
|
||||
- **Evaluate total work:** saving context is useful only if it does not cause more searches, repeated reads, or mistakes.
|
||||
Loaded 100 of 1361 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user