Merge branch 'master' into cleanup/compact-persistence-schema
This commit is contained in:
commit
41c10a62c2
551 files changed
+10611
-1713
No files matched your search
@@ -12,8 +12,8 @@
|
||||
en: 055a5e49f9af3dc2
|
||||
zh: 9f7c78409cccfded
|
||||
/agent-notes/archiving-and-deletion:
|
||||
en: 6c393c7058c787d7
|
||||
zh: 70ce9ca3bd9c2133
|
||||
en: 57ccc5fec461ebb0
|
||||
zh: 69e8f9e79abc53aa
|
||||
/agent-notes/when-to-write-one:
|
||||
en: 2beb1b94d8fb633c
|
||||
zh: 889b5542bb771205
|
||||
|
||||
@@ -39,7 +39,7 @@ Delete an implemented Agent Note that only describes small UI adjustments or pur
|
||||
|
||||
The archive is path-encoded as `archived/{class}/yyyy-mm-dd-topic-title.md`; `implemented` is deliberately absent because only implemented notes can enter it. An archival change moves the complete English/Chinese/sidecar triplet, retains `Status: implemented`, inserts the same `Archived: YYYY-MM-DD` line immediately below that status in both language files, re-records the sidecar, and repairs or deletes inbound links. These are the only permitted content changes during archival. Existing title punctuation, blank-line layout, and language-switcher wording do not block archival; preserve them with the body.
|
||||
|
||||
Once sealed, every archived triplet is permanently frozen. Do not edit, translate, reformat, update, move, or delete it, and do not treat it as authority for current behavior. Documentation gates skip archived sources, including their outbound links; active prose may still link into an archived note when it intentionally cites history. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) enforces the closed class tree, complete triplets, archive metadata, sidecar hashes, and the append-only frozen-content manifest. The [archive-policy Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) owns the rationale.
|
||||
Once sealed, every archived triplet is permanently frozen. Do not edit, translate, reformat, update, move, or delete it, and do not treat it as authority for current behavior. Documentation gates skip archived sources, including their outbound links; active prose may still link into an archived note when it intentionally cites history. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) enforces the closed class tree, complete triplets, archive metadata, sidecar hashes, and the append-only frozen-content manifest, except for the exact seal transitions documented in [archive instructions](archived/AGENTS.md). The [archive-policy Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) owns the rationale.
|
||||
|
||||
## When to write one
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 既有标题标点、空行布局和语言切换栏措辞不阻止归档;与正文一起保留。
|
||||
|
||||
封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。
|
||||
封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单),但[归档说明](archived/AGENTS.md)中记录的精确封存哈希转换除外。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。
|
||||
|
||||
<a id="when-to-write-one"></a>
|
||||
|
||||
|
||||
@@ -5,3 +5,5 @@ Archived Agent Note triplets under the kind directories are frozen historical sn
|
||||
The archival change may only relocate a complete English/Chinese/sidecar triplet, insert the identical `Archived: YYYY-MM-DD` line below both `Status: implemented` lines, re-record the sidecar, and repair or delete inbound links. Do not inspect, verify, or repair links out of archived notes.
|
||||
|
||||
Run the [`dsh-archive-agent-notes`](../../skills/dsh-archive-agent-notes/SKILL.md) workflow and append new artifact hashes with `pnpm run verify-archived-agent-notes --write`. The normal verifier rejects changed or missing sealed artifacts, incomplete triplets, unknown kind folders, and invalid archive metadata.
|
||||
|
||||
The authorized Figma-link removal from `feature/2026-08-10-durable-workflow-runs-in-chat` has three exact old-to-new seal exceptions in `scripts/archived-agent-notes.ts` for its English, Chinese, and pairing files. These exceptions permit no other content change, reversal, or deletion.
|
||||
+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
|
||||
2026-09-24-web-default-schedule-composition.md: 3f27bb09ebc00d42c32563e17a1de82c8e525be1
|
||||
2026-09-24-web-default-schedule-composition.zh.md: 9e0e1fb77ce89b579e1bc8b0d4d0a1b9f4e9fea5
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: Schedule in the shipped Web composition
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-25
|
||||
|
||||
English | [中文](2026-09-24-web-default-schedule-composition.zh.md)
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 默认 Web 组合中的 Schedule
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-09-25
|
||||
|
||||
[English](2026-09-24-web-default-schedule-composition.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/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md
|
||||
2026-08-10-durable-workflow-runs-in-chat.md: 01c59ef25996d6349ab2904f148bf4e72fdd5f09
|
||||
2026-08-10-durable-workflow-runs-in-chat.zh.md: 5f6b39b59beca5e2363261605ec58fbdcf71ea6f
|
||||
2026-08-10-durable-workflow-runs-in-chat.md: a49cb75e44bd65ef694679eeb295cfef564c7545
|
||||
2026-08-10-durable-workflow-runs-in-chat.zh.md: 753e02238cd8c00d0494cb673ba9896d61ff4cd8
|
||||
@@ -25,7 +25,7 @@ The renderer gives each level a distinct visual responsibility. The run uses a 3
|
||||
|
||||
Navigation is derived from two current authorities rather than persisted. A member row is interactive only while its durable member state is running and the current ordinary Session list contains the same id with `origin: 'subagent'`, `parentId` equal to the displayed parent, and `running: true`. Underlined member text is the only visible affordance; keyboard focus draws a two-pixel business-primary ring around the name area, and the fixed status label remains the lifecycle word rather than an action instruction. The renderer invokes only the injected ordinary `sessions.open(id)` callback. Addressed-only, remote, wrong-parent, and terminal members remain visible but static.
|
||||
|
||||
The [seven-state Figma reference](https://www.figma.com/design/tguwzZRmHCjbq58mfsqT0M?node-id=5-2) fixes the information hierarchy for running expanded/collapsed, completed history/expanded, failed plus cancelled, interrupted recovery, and dark narrow presentation. Repository `DisclosureRow`, `StateDot`, icons, semantic tokens, and keyed-node behavior remain the implementation authority; the reference introduces no runtime field or state owner.
|
||||
The seven-state Figma reference fixes the information hierarchy for running expanded/collapsed, completed history/expanded, failed plus cancelled, interrupted recovery, and dark narrow presentation. Repository `DisclosureRow`, `StateDot`, icons, semantic tokens, and keyed-node behavior remain the implementation authority; the reference introduces no runtime field or state owner.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-pl
|
||||
|
||||
导航从两个当前权威派生,不写入持久记录。只有持久成员状态仍为运行中,且当前普通 Session 列表包含同一 id、`origin: 'subagent'`、`parentId` 等于当前父 Session、`running: true` 时,成员行才可交互。带下划线的成员文字是唯一可见提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,固定状态列继续只表达生命周期,而不写动作说明。renderer 只调用注入的普通 `sessions.open(id)` 回调。仅地址化、远程、父级不符或终态成员继续可见,但保持静态。
|
||||
|
||||
[七状态 Figma 参考](https://www.figma.com/design/tguwzZRmHCjbq58mfsqT0M?node-id=5-2)固定运行展开/收起、完成历史/展开、失败与取消、恢复后中断以及暗色窄列的信息层级。仓库的 `DisclosureRow`、`StateDot`、图标、语义 token 和 keyed-node 行为仍是实现权威;参考稿不引入运行时字段或状态 owner。
|
||||
七状态 Figma 参考固定运行展开/收起、完成历史/展开、失败与取消、恢复后中断以及暗色窄列的信息层级。仓库的 `DisclosureRow`、`StateDot`、图标、语义 token 和 keyed-node 行为仍是实现权威;参考稿不引入运行时字段或状态 owner。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@@ -376,6 +376,9 @@
|
||||
"architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml": "sha256:af027c7dae53a181e4d73fd2309f69e9ae3c48df0d3b1dfc53cae274aa60d69e",
|
||||
"architecture/2026-09-11-wasm-preview-font-and-image-budgets.md": "sha256:44dd52839cdb67644c0dbcbc4beb6f36802628c4a8d17a0aad11db1f9be649ed",
|
||||
"architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md": "sha256:8285432ee1676be8b79a05953c4b6ac4a7ca5d8e701173b3a2b533608b584d0a",
|
||||
"architecture/2026-09-24-web-default-schedule-composition.i18n.yaml": "sha256:8c3d5c614924d0e81f361a299427ac697aefc6189c41eb3f4151f5c42e371ca0",
|
||||
"architecture/2026-09-24-web-default-schedule-composition.md": "sha256:3a139593ade13f95cbb67094c1eec66cb0a2c14bcc154512799803b2a7d22ab4",
|
||||
"architecture/2026-09-24-web-default-schedule-composition.zh.md": "sha256:59908f7987058742d1226dc8a801d03c265c84d51172249ba51716b91f084f0c",
|
||||
"bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml": "sha256:8be5b0afd8820c593e2ec254d35fb8c384267814104f6234bb9af824c5f974ba",
|
||||
"bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md": "sha256:861e6130e893489271478def5f54f212a106a1580b744e6e40f56356f73c7e10",
|
||||
"bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md": "sha256:3549257e06f074591de580ebfaa71284c7c384fb0ffb1963c30258d7a7c26db7",
|
||||
@@ -1264,9 +1267,9 @@
|
||||
"feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml": "sha256:74f519839f0cf82c7304bdeae41ae1cab8bb930bfb94f79ab44708acd3b72128",
|
||||
"feature/2026-08-10-creator-guidance-introduce-cue.md": "sha256:3e25409dda498de150de18943ee332e1760963e377a40a365902b66f667fdc9f",
|
||||
"feature/2026-08-10-creator-guidance-introduce-cue.zh.md": "sha256:203847010cab9e9d13c3969f17921d3a5aa0d69377eccfef555c7ff96572f162",
|
||||
"feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml": "sha256:0db3c21a3c8785e56d5d7b90df990b158069cd1cea664e62fc4786c6d7588450",
|
||||
"feature/2026-08-10-durable-workflow-runs-in-chat.md": "sha256:f9f5290cd880908d17b1080ae5776f22e182f5253471f90cf8bead418b33b502",
|
||||
"feature/2026-08-10-durable-workflow-runs-in-chat.zh.md": "sha256:f4ffd2700bc9c2a0cc4b9d90f84e1d0a576939e2e6f0b130c5b142fd8cd518b0",
|
||||
"feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml": "sha256:03eadb60c8ba4fde262cd61e3f911d8cd68ea4a58f57adeb8dd7bb9a97d08ca2",
|
||||
"feature/2026-08-10-durable-workflow-runs-in-chat.md": "sha256:6018de4a89ca99d6cbfd618aff1c8136b23d8e4854a30f7b12a602f0417cbdc2",
|
||||
"feature/2026-08-10-durable-workflow-runs-in-chat.zh.md": "sha256:0080859221773122022d5880efa3d52f29c0872b52d63e170250d94f214ff37a",
|
||||
"feature/2026-08-10-minimal-read-image-tool.i18n.yaml": "sha256:2311b6453b90a1f81d4fcbdaa723ab233cc0998f34393956364c8b92bc4d1213",
|
||||
"feature/2026-08-10-minimal-read-image-tool.md": "sha256:debe6b570d11634f57a80eaa5891e4fd1e7a5aac19f10fb7dd1683c316515150",
|
||||
"feature/2026-08-10-minimal-read-image-tool.zh.md": "sha256:572d080340682b354cb1f8a0f74af153d6542bd3dffbe3b72b32591e5d5d3baf",
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
en: 21d7ec9a9ff3cbf2
|
||||
zh: 93d03b8b3012aa7e
|
||||
/agent-note-host-owned-scheduled-messages/consequences:
|
||||
en: 0358aab730e4bf56
|
||||
zh: 61e89e0374891f8a
|
||||
en: a48f13f89a1944b1
|
||||
zh: f8fa677122ce2e12
|
||||
@@ -58,7 +58,7 @@ Session inbox persistence and the task put occur in separate persistence systems
|
||||
|
||||
**Keep the mock's hourly choice out of the stored vocabulary.** The approved mock lists 每小时整点 beside the daily, weekly and one-shot choices. Hourly and fixed-rate intervals are the same elapsed-time rule, so the card maps that menu slot onto the existing interval kind instead of adding a persisted kind. A dedicated hourly kind would need a new durable variant, its decoder, its target resolution, and its tool schema, all to express what an interval already expresses.
|
||||
|
||||
Task discovery and timers do not depend on loaded Sessions. The after, absolute-time, and fixed-rate selectors retain their semantics alongside Daily, Weekly, and Cron; reading existing saved tasks does not rewrite them. The Host must have its ordinary Session persistence and recovery services available, and the Web bundle mounts Schedule in its host row list. Explicit deletion stops future delivery and removes the task from `list` and `catalog` without deleting conversation messages; the removed row takes its saved receipts with it. History is pruned to the configured window and record cap when an acknowledgment is appended, loaded into Host memory, and rewritten with the task on each acknowledgment. Pagination limits the returned record count; the plugin configuration (`deliveryHistoryDays`, default 30, and `deliveryHistoryRecords`, default 200) limits storage growth. Earlier overwritten receipts and deliveries whose task write failed cannot be reconstructed. Pause and execution-history operations remain unsupported.
|
||||
Task discovery and timers do not depend on loaded Sessions. The after, absolute-time, and fixed-rate selectors retain their semantics alongside Daily, Weekly, and Cron; reading existing saved tasks does not rewrite them. The Host must have its ordinary Session persistence and recovery services available, and enabling the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` mounts Schedule; the Web composition carries its row disabled. Explicit deletion stops future delivery and removes the task from `list` and `catalog` without deleting conversation messages; the removed row takes its saved receipts with it. History is pruned to the configured window and record cap when an acknowledgment is appended, loaded into Host memory, and rewritten with the task on each acknowledgment. Pagination limits the returned record count; the plugin configuration (`deliveryHistoryDays`, default 30, and `deliveryHistoryRecords`, default 200) limits storage growth. Earlier overwritten receipts and deliveries whose task write failed cannot be reconstructed. Pause and execution-history operations remain unsupported.
|
||||
|
||||
Required coverage includes startup from stored tasks, missing-status normalization, cold Session restoration, latest-only catch-up, inactive-task retention without repeat dispatch, real message receipts, deletion across restart, removal of a deleted task's saved deliveries, queue/persistence failures, and disposal. Wall-clock obligations include gaps and skipped dates, earlier-only overlaps, fractional seconds, UTC-range edges, committed-target and alias preservation, and post-restoration clock rollback. Weekly adds weekday normalization and validation, next-target selection across a gap and an overlap, and stored set preservation. Historical decoders must reject Daily, Weekly, and Cron while Host storage accepts them. Client coverage includes every recurring kind, exact rule time, zone, and weekday set, All/Enabled/Inactive status filters, retained inactive details and original-Session navigation, authoritative refresh after confirmed deletion and notification, empty lists, reconnect, and late responses. Presentation coverage includes the page's New action starting a Session, the transcript card's narrowing of persisted result JSON including an incomplete or replayed result, the header clock's direct open for exactly one task and its count label, the idle-only row mark with the hover-card two-task limit and omission line. History coverage includes legacy byte preservation, append and restart, immutable sent instructions, strict latest-receipt consistency, stable cursor pagination, binding checks, failed persistence, and late page replies during refresh or task changes. Cross-store crash duplication remains an explicit limitation rather than a completion claim.
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ Session 收件箱持久化和任务 put 发生在两个持久化系统中。收
|
||||
|
||||
**不把 mock 的「每小时整点」引入持久词表。** 设计稿把它与每天、每周、仅一次并列。每小时整点与固定间隔在语义上是同一种按经过时间计量的规则,因此详情里的该项直接映射到既有的间隔类型,而不是新增一个持久种类。若真要新增,需要新的持久变体、解码器、目标解析与工具 schema,而它表达的能力现有间隔已经覆盖。
|
||||
|
||||
任务发现和 timer 不依赖已加载的 Session。延迟、绝对时间和固定速率选择器在与 Daily、Weekly、Cron 并存时保留自身语义;读取已有任务不会重写它们。Host 必须提供普通 Session 持久化和恢复服务,Web bundle 在其宿主行列表中挂载 Schedule。显式删除会阻止后续投递,并将任务从 `list` 和 `catalog` 中移除,不删除对话消息;被移除的行会带走其已保存回执。历史在追加确认时按配置的窗口与条数上限裁剪,随后加载到 Host 内存,并在每次确认时随任务一起重写。分页限制返回记录数量,不限制存储增长或指令字节数。此前被覆盖的回执,以及任务写入失败的投递记录,无法重建。暂停和执行历史操作仍不受支持。
|
||||
任务发现和 timer 不依赖已加载的 Session。延迟、绝对时间和固定速率选择器在与 Daily、Weekly、Cron 并存时保留自身语义;读取已有任务不会重写它们。Host 必须提供普通 Session 持久化和恢复服务,而挂载 Schedule 需要启用可选实验 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`;Web 组合以禁用状态携带其条目。显式删除会阻止后续投递,并将任务从 `list` 和 `catalog` 中移除,不删除对话消息;被移除的行会带走其已保存回执。历史在追加确认时按配置的窗口与条数上限裁剪,随后加载到 Host 内存,并在每次确认时随任务一起重写。分页限制返回记录数量,不限制存储增长或指令字节数。此前被覆盖的回执,以及任务写入失败的投递记录,无法重建。暂停和执行历史操作仍不受支持。
|
||||
|
||||
必要覆盖范围包括从已存储任务启动、缺失状态规范化、冷 Session 恢复、仅补发最新发生时点、保留未运行任务且不重复投递、真实消息回执、重启后的删除结果、删除会移除已保存发送记录、排队或持久化失败,以及销毁。钟表规则的验证要求包括缺口和跳过的日期、仅选择较早时点的重叠、小数秒、UTC 范围边缘、已提交目标和别名保留,以及恢复后的时钟回拨。Weekly 另需覆盖星期集合的规范化与校验、跨缺口与重叠的下一目标选择,以及已存集合的保留。历史解码器必须拒绝 Daily、Weekly 与 Cron,而宿主存储接受它们。客户端覆盖范围包括每种重复规则、精确规则时间、时区和星期集合、全部/已开启/未运行状态筛选、保留未运行任务详情和原 Session 导航、确认删除和通知后的权威刷新、空列表、重新连接和晚到响应。呈现覆盖范围包括页面“新建”操作打开新会话、transcript 卡片从不完整或回放的结果 JSON 中收窄任务、页头时钟在恰好一个任务时直接打开并给出数量标签、仅空闲行标记及其悬浮卡片两个任务上限与遗漏行。历史覆盖范围包括旧文件字节保留、追加与重启、不可变的发送指令、最近回执一致性严格校验、稳定游标分页、绑定校验、持久化失败,以及刷新或切换任务期间晚到的分页响应。跨存储崩溃导致的重复投递仍是明确限制,不能被表述为完成保证。
|
||||
|
||||
|
||||
+4
-4
@@ -9,11 +9,11 @@
|
||||
en: 2ba6c43b20a3d347
|
||||
zh: 5647179ddaa38ad9
|
||||
/agent-note-ship-experimental-capabilities-as-optional-bundles/decision:
|
||||
en: 1c5a43b3ce2d3056
|
||||
zh: b59f549a12cf67d1
|
||||
en: fdd54ede661f7a83
|
||||
zh: a41ed1a872bb8ba3
|
||||
/agent-note-ship-experimental-capabilities-as-optional-bundles/alternatives-considered:
|
||||
en: a65d84004e48b3ef
|
||||
zh: 17221f08e7a8a89b
|
||||
/agent-note-ship-experimental-capabilities-as-optional-bundles/consequences:
|
||||
en: d52ed0a248131a37
|
||||
zh: 779b8d415281c5da
|
||||
en: e7fb2a93a89ba9b6
|
||||
zh: 37ed9f0a3ca44583
|
||||
+2
-2
@@ -10,7 +10,7 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A
|
||||
|
||||
## Decision
|
||||
|
||||
`OPTIONAL_BUNDLES` lists Agent Teams, voice input, and Auto review. Every optional bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`, so the Official group renders a localized title, description, and image; `verify-default-product-isolation` rejects an optional bundle without them. The Inspector's `cordis.patch.yml` names the package instead of a built-artifact path, so the same file is the bundle patch and the `--patch` overlay for a built Web launch.
|
||||
`OPTIONAL_BUNDLES` lists Agent Teams, voice input, Auto review, and the Schedule bundle. Every optional bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`, so the Official group renders a localized title, description, and image; `verify-default-product-isolation` rejects an optional bundle without them. The Inspector's `cordis.patch.yml` names the package instead of a built-artifact path, so the same file is the bundle patch and the `--patch` overlay for a built Web launch.
|
||||
|
||||
An optional bundle is a runtime dependency of the installation, so its dependency graph is downloaded by every `dsh` install. The list therefore admits only packages whose graph is already in the installation's closure; Auto review adds nothing. The Inspector stays available through explicit installation and does not appear in the default plugin list. The browser-use and computer-use providers stay explicit compositions: Playwright MCP, Chrome DevTools MCP, and the native Cua Driver runtime binaries add about 85 MB and 21 packages to every install whether or not the bundle is enabled, and a shipped Cua Driver MCP switch would offer a capability whose executable the installation does not carry. Those provider packages keep locale display metadata for their component rows. Two more packages stay out for other reasons: `ptc-runtime-python` replaces the PTC runtime, and `workflow-ptc` rejects a non-TypeScript runtime at load while the Web presets carry `workflow-ptc` rows a bundle patch cannot reach; `browser-use-stagehand-native` requires a native model name and API key at schema validation with no configuration form on the page.
|
||||
|
||||
@@ -22,4 +22,4 @@ An optional bundle is a runtime dependency of the installation, so its dependenc
|
||||
|
||||
## Consequences
|
||||
|
||||
The Official group grows from two to three entries, each tagged experimental by its experimental name. The runtime dependency closure of the installation is unchanged. A browser-use or computer-use provider still needs its Service Definition and the provider in a profile patch or composition.
|
||||
The Official group carries four optional-bundle entries, each tagged experimental by its experimental name. The runtime dependency closure of the installation is unchanged. A browser-use or computer-use provider still needs its Service Definition and the provider in a profile patch or composition.
|
||||
+2
-2
@@ -10,7 +10,7 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r
|
||||
|
||||
## 决策
|
||||
|
||||
`OPTIONAL_BUNDLES` 列出 Agent Teams、语音输入与 Auto review。每个可选 bundle 都声明 `icon` 并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`,官方分组因此能渲染本地化标题、描述和图片;`verify-default-product-isolation` 会拒绝缺少这些声明的可选 bundle。Inspector 的 `cordis.patch.yml` 直接写包名而非构建产物路径,所以同一文件既是 bundle patch,也是已构建 Web 启动的 `--patch` overlay。
|
||||
`OPTIONAL_BUNDLES` 列出 Agent Teams、语音输入、Auto review 与 Schedule bundle。每个可选 bundle 都声明 `icon` 并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`,官方分组因此能渲染本地化标题、描述和图片;`verify-default-product-isolation` 会拒绝缺少这些声明的可选 bundle。Inspector 的 `cordis.patch.yml` 直接写包名而非构建产物路径,所以同一文件既是 bundle patch,也是已构建 Web 启动的 `--patch` overlay。
|
||||
|
||||
可选 bundle 是安装的运行时依赖,其依赖图会随每次 `dsh` 安装一起下载。因此名单只接纳依赖图已在安装闭包内的包;Auto review 不增加任何依赖。Inspector 保留显式安装方式,不在默认插件列表中显示。浏览器操作与电脑操作提供方仍保持显式组合:Playwright MCP、Chrome DevTools MCP 与原生 Cua Driver 运行时二进制会让每次安装多出约 85 MB、21 个包,无论 bundle 是否启用;而随附的 Cua Driver MCP 开关会提供一个安装本身并不携带其可执行文件的能力。这些提供方包保留 locale 显示元数据,供组件行使用。另有两个包因其他原因不纳入:`ptc-runtime-python` 会替换 PTC 运行时,而 `workflow-ptc` 在加载时拒绝非 TypeScript 运行时,且 Web preset 内含 bundle patch 无法触及的 `workflow-ptc` 行;`browser-use-stagehand-native` 在 schema 校验时就要求原生模型名称与 API 密钥,而插件页没有对应的配置表单。
|
||||
|
||||
@@ -22,4 +22,4 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r
|
||||
|
||||
## 后果
|
||||
|
||||
官方分组从两项增至三项,每项都因实验包名而带实验性标签。安装的运行时依赖闭包保持不变。浏览器操作或电脑操作提供方仍需在 profile patch 或组合中同时挂载其 Service Definition 与提供方。
|
||||
官方分组包含四个可选 bundle 条目,每项都因实验包名而带实验性标签。安装的运行时依赖闭包保持不变。浏览器操作或电脑操作提供方仍需在 profile patch 或组合中同时挂载其 Service Definition 与提供方。
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-24-schedule-opt-in-optional-bundle.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md
|
||||
/agent-note-schedule-as-an-opt-in-optional-bundle:
|
||||
en: 8c2381fd778a9888
|
||||
zh: 8877255cb980eea1
|
||||
/agent-note-schedule-as-an-opt-in-optional-bundle/problem:
|
||||
en: bb23c2f7f93a462b
|
||||
zh: 9fb600fc8e203323
|
||||
/agent-note-schedule-as-an-opt-in-optional-bundle/decision:
|
||||
en: 483d1dd51b9cc073
|
||||
zh: b069e2e2385c8132
|
||||
/agent-note-schedule-as-an-opt-in-optional-bundle/alternatives-considered:
|
||||
en: 8e5f4d8fa8c2e78d
|
||||
zh: 3353c7042c6a1d6b
|
||||
/agent-note-schedule-as-an-opt-in-optional-bundle/consequences:
|
||||
en: 36cd0eb71abeedbe
|
||||
zh: 7c134095cc19fcfd
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Schedule as an opt-in optional bundle
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-24-schedule-opt-in-optional-bundle.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Schedule adds four tool schemas to every live root Agent request and one durable clock message per eligible step, so the shipped Web composition must not mount it by default. A person using the shipped Web profile still needs a product-surface switch to turn it on; a profile patch layer or a `--patch` overlay is a configuration file that person cannot reach.
|
||||
|
||||
## Decision
|
||||
|
||||
`packages/bundle/web-app/cordis.patch.yml` carries none of `time-context`, `schedule`, and `ui-schedule`, so the shipped Web composition exposes no Schedule surface: no `schedule_*` tools, no Session reminder catalog, no Automation tasks page, and no per-step clock reading.
|
||||
|
||||
`@deepseek-ai/dsh-experimental-schedule-bundle` (`packages/experimental/schedule-bundle/`) inserts the three rows in its `cordis.patch.yml` and depends on their packages, as every other optional bundle inserts the rows it ships. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names the package, and `apps/cli` declares it as a runtime dependency, so every installation ships it switched off. The [experimental-as-optional-bundles decision](2026-09-21-experimental-capabilities-as-optional-bundles.md) owns the `OPTIONAL_BUNDLES` conventions and the localized `icon` and `meta.title` / `meta.description` metadata the Web Plugins page renders in its Official group.
|
||||
|
||||
Enabling the bundle inserts `time-context` (a per-step clock reading with the sampled instant, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message), `schedule` (durable reminders plus `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` on live root Agents), and `ui-schedule` (the Session reminder catalog and the Automation tasks page). The bundle carries `time-context` with `schedule` because a reminder request states a wall-clock target: the sampled instant is what lets the model turn a request such as "tomorrow at nine" into an offset-bearing `at` value. The [Schedule subsystem](../../../../docs/subsystems/schedule.md) owns the durable records, delivery, and management operations; the [Web bundle](../../../../packages/bundle/web-app/README.md) owns the composition the bundle adds the rows to. Disabling the bundle restores the shipped composition, and the Host stops scheduling while the `schedule` row is absent; stored task records remain in the Schedule domain.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Ship Schedule on by default.** Every Web session then pays four tool schemas in each request header and one durable user message per eligible step, and a conversation that never creates a reminder pays both costs. The arrangement also left the capability without an off switch on the product surface.
|
||||
|
||||
**Keep a `--patch` overlay file.** An overlay is a launch-time argument rather than a product-surface switch, so a person using the shipped Web profile cannot reach it. Re-declaring the rows through its `insert` list does not override them: `applyEntryPatches` appends that list without de-duplicating ids, and the Loader collapses the composed list to one entry per id with the last declaration winning, so the overlay replaces the Web bundle's rows instead of setting fields on them.
|
||||
|
||||
**Keep the rows in the Web composition with `disabled: true` and switch them on with id-targeted patches.** The bundle would then set fields on existing rows, but the plugin manager lists only the rows a bundle inserts, so the bundle's page reported no components and needed a second listing path for overridden rows, while every other optional bundle inserts its rows. A profile that also declares one of the ids resolves the same way as for any other bundle: the Loader keeps the last declaration.
|
||||
|
||||
**Lock the three rows to the bundle switch.** A bundle manifest field that makes the plugin manager refuse to switch the rows one by one adds a plugin-manager concept only this bundle would use. The bundle's page offers a switch per row, as for every other bundle, and the bundle README records that the three rows work only together.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A default `dsh web` session carries no Schedule tool schemas, no Session reminder catalog, no Automation tasks page, and no per-step clock reading. An installation that wants them enables the optional bundle from the Plugins page or lists the package in a profile's `dsh.profile.bundles`.
|
||||
- An enabled installation adds four tool schemas to every live root Agent and one durable clock message per eligible step; the reading is model-visible and durable, so it replays, compacts, and appears in exported Session logs like any other user message.
|
||||
- The Plugins page lists the three rows under the bundle with the titles their packages' `locale/*.json` declare, their state, and, while the bundle is on, a switch per row; switching one of them off leaves Schedule without that part.
|
||||
- A profile patch or `--patch` overlay that targets one of the three ids matches no row while the bundle is not selected, and the loader warns `patch: entry <id> not found` for it; selecting the bundle replaces switching the rows on by id.
|
||||
- The switch is configuration-only: `src/index.ts` is an empty module, the patch carries the runtime content, and the package owns no mutable runtime state, so it publishes no invariant companion.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Schedule 作为按需开启的可选 bundle
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-24-schedule-opt-in-optional-bundle.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Schedule 会给每个活跃根 Agent 的请求增加四个工具 schema,并在每个符合条件的步骤追加一条持久时钟消息,因此随发行版交付的 Web 组合不能默认挂载它。使用随发行版 Web profile 的人仍需要产品界面上的开关来打开它;profile patch 层或 `--patch` overlay 都是这个人无法触及的配置文件。
|
||||
|
||||
## Decision
|
||||
|
||||
`packages/bundle/web-app/cordis.patch.yml` 不含 `time-context`、`schedule` 与 `ui-schedule` 中的任何一行,因此随发行版交付的 Web 组合不提供任何定时能力:没有 `schedule_*` 工具、没有 Session 提醒目录、没有自动化任务页面,也没有逐步时钟读数。
|
||||
|
||||
`@deepseek-ai/dsh-experimental-schedule-bundle`(`packages/experimental/schedule-bundle/`)在其 `cordis.patch.yml` 中插入这三行并依赖它们的包,与其他可选 bundle 插入各自随附的行相同。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出该包,`apps/cli` 将其声明为运行时依赖,因此每次安装都随包携带且默认关闭。[实验性能力作为可选 bundle 的决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)负责 `OPTIONAL_BUNDLES` 的约定,以及 Web 插件管理页在“官方”分组中渲染的本地化 `icon` 与 `meta.title` / `meta.description` 元数据。
|
||||
|
||||
启用该 bundle 会插入 `time-context`(每个步骤的时钟读数,包含采样瞬时、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长)、`schedule`(持久提醒,以及每个活跃根 Agent 上的 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`)与 `ui-schedule`(Session 提醒目录与自动化任务页面)。该 bundle 把 `time-context` 与 `schedule` 一起携带,因为提醒请求给出的是挂钟时间目标:采样瞬时正是让模型把“明天九点”这类请求转换成带偏移量的 `at` 值的依据。持久记录、投递与管理操作由 [Schedule 子系统](../../../../docs/subsystems/schedule.zh.md)负责;该 bundle 向其加入这些行的组合由 [Web bundle](../../../../packages/bundle/web-app/README.zh.md) 负责。禁用该 bundle 会恢复随发行版交付的组合,`schedule` 行不存在期间 Host 不再调度;已存储的任务记录保留在 Schedule domain 中。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**默认发布 Schedule。** 那样每个 Web 会话都要在每个请求头中带上四个工具 schema,并在每个符合条件的步骤追加一条持久 user 消息,从不创建提醒的对话也要承担这两项成本。这种安排还让该能力在产品界面上没有关闭开关。
|
||||
|
||||
**保留一个 `--patch` overlay 文件。** overlay 是启动时的参数,而不是产品界面上的开关,因此使用随发行版 `web` profile 的人无法触及它。通过它的 `insert` 列表重新声明这些条目并不能覆盖它们:`applyEntryPatches` 追加该列表时不对 id 去重,而 Loader 会把组合出的列表按 id 收敛为一个条目、最后一个声明生效,因此该 overlay 会替换 Web Bundle 的条目,而不是给它们设值。
|
||||
|
||||
**把这三行以 `disabled: true` 留在 Web 组合中,用按 id 定位的 patch 打开。** 这样 bundle 只是给已有的行设值,但插件管理器只列出 bundle 插入的行,因此该 bundle 的页面显示不含任何组件,需要为被覆盖的行另建一条列出路径;而其他可选 bundle 都插入各自的行。某个 profile 若也声明了其中某个 id,处理方式与任何其他 bundle 相同:Loader 保留最后一份声明。
|
||||
|
||||
**把这三行锁定在 bundle 开关上。** 让插件管理器拒绝逐行开关的 bundle 清单字段,会给插件管理器增加一个只有这个 bundle 使用的概念。该 bundle 的页面像其他所有 bundle 一样为每行提供开关,由 bundle README 说明这三行只能一起工作。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 默认的 `dsh web` 会话不带 Schedule 工具 schema、Session 提醒目录、自动化任务页面与逐步时钟读数。需要它们的安装可从插件管理页启用该可选 bundle,或把该包列进 profile 的 `dsh.profile.bundles`。
|
||||
- 启用后的安装会给每个活跃根 Agent 增加四个工具 schema,并在每个符合条件的步骤追加一条持久时钟消息;该读数对模型可见且持久,因此它与其他 user 消息一样参与回放、压缩,并出现在导出的 Session 日志中。
|
||||
- 插件管理页在该 bundle 下列出这三行,显示各自包的 `locale/*.json` 声明的标题、它们的状态,并在 bundle 打开时为每行提供开关;关掉其中一行会让 Schedule 缺少这一部分。
|
||||
- 未选中该 bundle 时,按 id 定位这三行之一的 profile 补丁或 `--patch` overlay 匹配不到任何条目,加载器会为它报告 `patch: entry <id> not found` 警告;选中该 bundle 即可取代按 id 打开这些行。
|
||||
- 该开关只涉及配置:`src/index.ts` 是空模块,运行时内容由 patch 承载,包本身不拥有可变的运行时状态,因此不发布不变量伴随模块。
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
# Bilingual-pair consistency record for 2026-09-24-web-default-schedule-composition.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.md
|
||||
/agent-note-schedule-in-the-shipped-web-composition:
|
||||
en: 39229d7ab6ed433b
|
||||
zh: 54287471d371a11c
|
||||
/agent-note-schedule-in-the-shipped-web-composition/problem:
|
||||
en: 2871fd5ed881c007
|
||||
zh: d03c78d402d186da
|
||||
/agent-note-schedule-in-the-shipped-web-composition/decision:
|
||||
en: bb19ad12b2ab0ffc
|
||||
zh: 971f33e95474a36c
|
||||
/agent-note-schedule-in-the-shipped-web-composition/recorded-sessions:
|
||||
en: 5539a42052f4ddb7
|
||||
zh: 98cc190ae63b4549
|
||||
/agent-note-schedule-in-the-shipped-web-composition/alternatives-considered:
|
||||
en: ca032a407c2ee8cb
|
||||
zh: 748a4fcf2485a3da
|
||||
/agent-note-schedule-in-the-shipped-web-composition/consequences:
|
||||
en: 4f23bda8fda37987
|
||||
zh: 9f7c4a3538bd2979
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-25-session-log-otel-byte-limits.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.md
|
||||
/agent-note-session-log-otlp-strings-and-bounded-requests:
|
||||
en: 041f7e2b55202ea3
|
||||
zh: e01834ae6c9fc7bc
|
||||
/agent-note-session-log-otlp-strings-and-bounded-requests/problem:
|
||||
en: 20f659a15fb55352
|
||||
zh: 49d39c95da1b8aa5
|
||||
/agent-note-session-log-otlp-strings-and-bounded-requests/decision:
|
||||
en: 0f0866232326658b
|
||||
zh: aa608d17ba712d8a
|
||||
/agent-note-session-log-otlp-strings-and-bounded-requests/alternatives-considered:
|
||||
en: 0dc933b18a1a3573
|
||||
zh: 77655740322c4e45
|
||||
/agent-note-session-log-otlp-strings-and-bounded-requests/consequences:
|
||||
en: 3859569c9cdea30c
|
||||
zh: 517449b8c49807f6
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Session-log OTLP strings and bounded requests
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-25-session-log-otel-byte-limits.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Session logs contain nested JSON and large tool results. The product collector accepts named records with string content and limits request size; count-only SDK batching cannot bound bytes after UTF-8 encoding and JSON escaping.
|
||||
|
||||
## Decision
|
||||
|
||||
The shared `otel` Cordis plugin owns ordinary-event and Session-log transport implementations. Business adapters inject it and create independent caller-owned channels; their existing UI/RPC entry points retain authorization, redaction, identity, configuration, and bounded shutdown. Mounting the shared service creates no unused queues. Each canonical event becomes `eventName: "session-log"`, with `sessionId` and JSON-stringified event values in `content`; serialization does not preserve JSONL key ordering. Redaction runs before serialization and the copied envelope excludes the payload.
|
||||
|
||||
Session logs use a separate provider and byte/count queue. Each record is measured once with the SDK serializer including a full envelope; the sum conservatively bounds a combined request. Greedy packing avoids full-candidate serialization and recursive re-encoding. Single oversized records receive one diagnostic and do not block later events. One HTTP request owns the transport slot until settlement, even after its watchdog fires; shutdown expiry stops the remaining queue. Scope name/version and explicit transport options remain owned by the backend.
|
||||
|
||||
The [telemetry revival](../feature/2026-07-23-session-telemetry-otel-revival.md) remains authoritative for redaction, capture, and best-effort handoff. This decision replaces its SDK-only queue/batching rule: SDK batch deadlines cannot own an operation split into multiple HTTP requests. The shared Session-log channel schedules complete requests; the SDK still owns transport/retry. Feedback authorization and the separate DeepSeek model-request contribution remain unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**One string for the entire Session.** Rejected because one large Session would exceed the request ceiling even when each event fits. One event per record preserves event identity and permits batching.
|
||||
|
||||
**Truncate oversized records or add client-only fragments.** Rejected because truncation loses content and fragments require a collector reassembly protocol. Explicit rejection retains the one-event-per-record meaning.
|
||||
|
||||
**Count compressed bytes or characters.** Rejected because collector limits can apply after decompression, and characters omit UTF-8 and escaping overhead. Measuring full uncompressed requests gives a conservative bound.
|
||||
|
||||
## Consequences
|
||||
|
||||
Product and Session records never share a request or queue, and neither package imports the other. Conservative per-record envelope accounting can produce smaller batches than exact combined accounting. Queue overflow, oversize rejection, exit, and transport failure can lose data; no durable outbox or acknowledgement is added. Tests cover byte limits, linear measurement, slow-request serialization, metadata, redaction-copy isolation, and feedback-authorized replay.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Session 日志的 OTLP 字符串与有界请求
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-25-session-log-otel-byte-limits.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Session 日志包含嵌套 JSON 和大型工具结果。产品采集端接收带名称和字符串内容的记录,并限制请求大小;仅按数量分批的 SDK 无法约束 UTF-8 编码与 JSON 转义后的字节数。
|
||||
|
||||
## 决策
|
||||
|
||||
共享 `otel` Cordis 插件负责普通事件和 Session 日志的传输实现。业务适配器注入它并创建独立、由调用方持有的通道;现有 UI/RPC 入口保留授权、脱敏、身份、配置和有期限的关闭。只挂载共享服务不会创建无用队列。每条规范事件转换为 `eventName: "session-log"`,带有 `sessionId` 和在 `content` 中 JSON 字符串化的事件值;序列化不保留 JSONL 键顺序。脱敏先于序列化,复制的 envelope 不包含 payload。
|
||||
|
||||
Session 日志使用独立 provider 和字节/条数队列。每条记录使用 SDK 序列化器计量一次,包含完整 envelope;其总和保守约束合并请求大小。顺序组包避免候选批次的完整序列化和递归重复编码。单条超限记录只产生一次诊断,不阻塞后续事件。每个 HTTP 请求在结束前始终占有传输槽位,即使其监测期限已经触发;关闭期限到期后停止剩余队列。scope 名称/版本和显式传输选项仍由后端拥有。
|
||||
|
||||
[遥测恢复决策](../feature/2026-07-23-session-telemetry-otel-revival.zh.md) 继续约束脱敏、捕获和尽力交接。本决策替代其仅使用 SDK 队列/聚合的规则:SDK 批次期限无法拥有拆成多个 HTTP 请求的操作。共享 Session 日志通道调度完整请求,SDK 仍负责传输和重试。反馈授权和独立的 DeepSeek 模型请求附加路径不变。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**整个 Session 合为一个字符串。** 拒绝,因为即使每条事件都能放入请求,大型 Session 仍会超过请求上限。每条事件对应一条记录,既保留事件身份又允许分批。
|
||||
|
||||
**截断超限记录或仅在客户端分片。** 拒绝,因为截断会丢失内容,而分片需要采集端重组协议。显式拒绝保留一条事件对应一条记录的含义。
|
||||
|
||||
**计算压缩字节数或字符数。** 拒绝,因为采集端限制可能作用于解压后内容,而字符数不包含 UTF-8 和转义开销。计量完整未压缩请求提供保守上限。
|
||||
|
||||
## 影响
|
||||
|
||||
产品事件和 Session 记录不共享请求或队列,两个包也不相互导入。保守的逐记录 envelope 计量可能比精确合并计量产生更小批次。队列溢出、超限拒绝、退出和传输失败可能丢失数据;不新增持久 outbox 或确认机制。测试覆盖字节上限、线性计量、慢请求串行调度、元数据、脱敏复制隔离和反馈授权回放。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-19-failed-step-tool-results.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# 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-19-failed-step-tool-results.md
|
||||
/agent-note-record-unanswered-tool-results-before-closing-a-failed-step:
|
||||
en: 8fd2a50a041b1b18
|
||||
zh: 01aed07a6425a322
|
||||
/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/problem:
|
||||
en: 1fa7525f83af9035
|
||||
zh: 401ce968e3dd5434
|
||||
/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/decision:
|
||||
en: 3d58367a05183b4a
|
||||
zh: f9ccc3a7011a6240
|
||||
/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/alternatives-considered:
|
||||
en: 37da4d9c8533b736
|
||||
zh: e52a59763ae2b62e
|
||||
/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/consequences:
|
||||
en: 1ea72421a98551d8
|
||||
zh: c01e02ddac8a3454
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Record unanswered tool results before closing a failed step
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-19-failed-step-tool-results.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A committed assistant message can request tools whose scheduler fails before all results are recorded. Closing the step and turn with those calls unanswered makes later model requests invalid for providers that require paired tool calls and results. An error turn is already closed, so crash-tail repair cannot recover it on resume.
|
||||
|
||||
## Decision
|
||||
|
||||
The AgentLoop driver records recovery results for unanswered calls before it closes a failed step, then propagates the original failure. Live step recovery and `interruptedTurnClosers` share incremental pending-call tracking and result construction; the live driver still owns its step and turn events. A step-local observer retains pending identities from committed events without copying session history. The scheduler stops replenishment and drains started dispatches before rejecting, so result recovery cannot race a live dispatch.
|
||||
|
||||
Recovery preserves committed results and follows the assistant's call order. A recorded `tool/call` without a result receives `TOOL_OUTCOME_UNKNOWN`; its text warns against blindly retrying an operation with possible side effects. An assistant request with no call record receives `TOOL_NOT_STARTED`. A call record precedes preparation and does not establish that the tool body ran, so unknown outcomes remain conservative.
|
||||
|
||||
This decision supersedes only the missing-result policy in [parallel tool-call execution](../feature/2026-07-10-parallel-tool-call-execution.md). Its classification, concurrency limit, ordering, and quiescence rules remain applicable.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Leave calls unanswered to avoid inventing tool outcomes.** An unanswered call invalidates later provider requests. An explicit unknown-outcome error records the limit of the available evidence without claiming success, cancellation, or absence of side effects.
|
||||
|
||||
**Repair only when resuming or forking.** The ordinary failure path already records `step/end` and `turn/end`; open-tail repair intentionally preserves closed history. Recovery belongs before those records, while the driver owns the failed step.
|
||||
|
||||
**Repair inside the tool scheduler alone.** Failure can occur after the assistant message is recorded but before scheduling starts. The step driver covers that interval and reuses the existing session repair semantics rather than adding a second implementation of call/result matching.
|
||||
|
||||
**Retry automatically.** A dispatch can have side effects even when its result is missing. The error result supplies retry guidance, while the turn retains the original failure and waits for later work.
|
||||
|
||||
## Consequences
|
||||
|
||||
Later requests, resume, and fork retain a usable call/result history after a live step failure. Recovery appends results; it does not remove assistant messages, replace completed results, execute tools, or convert the failed turn into success. The log uses existing event types and recovery codes, with no format change.
|
||||
|
||||
Already-closed inconsistent historical turns remain unchanged. The live recovery path requires the session to accept the missing result records; it cannot promise recovery from a failed log append.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: 在关闭失败步骤前补齐工具结果
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-19-failed-step-tool-results.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
已提交的 assistant 消息可能请求多个工具,而调度器在全部结果记录完成之前失败。如果关闭步骤和轮次时仍有调用尚无结果,后续模型请求就不满足要求工具调用与结果配对的提供方。错误轮次已经关闭,因此恢复会话时的崩溃尾部修复无法恢复它。
|
||||
|
||||
## 决策
|
||||
|
||||
AgentLoop 驱动器在关闭失败步骤之前,为尚无结果的调用记录恢复结果,随后传播原始失败。实时步骤恢复和 `interruptedTurnClosers` 共享增量的待处理调用跟踪与结果构造;实时驱动器仍拥有自己的步骤和轮次事件。步骤本地观察器从已提交事件中保留待处理调用标识,不复制会话历史。调度器在拒绝之前停止补充池并排空已启动的派发,因此结果恢复不会与运行中的派发竞争。
|
||||
|
||||
恢复保留已提交的结果,并遵循 assistant 的调用顺序。已有 `tool/call` 记录但尚无结果的调用获得 `TOOL_OUTCOME_UNKNOWN`;其文本警告不要盲目重试可能存在副作用的操作。没有调用记录的 assistant 请求获得 `TOOL_NOT_STARTED`。调用记录先于准备阶段,不能证明工具主体已经运行,因此未知结果保持保守。
|
||||
|
||||
本决策仅取代[并行工具调用执行](../feature/2026-07-10-parallel-tool-call-execution.zh.md)中的缺失结果策略。其中的分类、并发上限、顺序和完全停稳规则继续适用。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**让调用保持无结果,避免虚构工具执行结果。** 尚无结果的调用会使后续提供方请求无效。显式的未知结果错误记录可用证据的局限,而不声称成功、取消或没有副作用。
|
||||
|
||||
**仅在恢复或 fork 时修复。** 普通失败路径已经记录 `step/end` 和 `turn/end`;开放尾部修复有意保留已关闭历史。恢复应在这些记录之前执行,此时驱动器仍拥有失败步骤。
|
||||
|
||||
**仅在工具调度器内部修复。** 失败可能发生在 assistant 消息记录之后、调度开始之前。步骤驱动器覆盖这段区间,并复用已有的会话修复语义,避免另行实现调用/结果匹配。
|
||||
|
||||
**自动重试。** 即使结果缺失,派发也可能产生副作用。错误结果提供重试指引,而轮次保留原始失败并等待后续工作。
|
||||
|
||||
## 影响
|
||||
|
||||
实时步骤失败后,后续请求、恢复和 fork 会保留可用的调用/结果历史。恢复追加结果;它不移除 assistant 消息、不替换已完成结果、不执行工具,也不将失败轮次改为成功。日志使用已有事件类型和恢复代码,无需格式变更。
|
||||
|
||||
此前已关闭的不一致历史轮次保持不变。实时恢复路径要求会话能够接纳缺失结果记录;它不能保证从日志追加失败中恢复。
|
||||
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-pwsh-prompt-tail-grace.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# 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-27-pwsh-prompt-tail-grace.md
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace:
|
||||
en: 5e8c5673a0669d9c
|
||||
zh: c8442a6186288a2a
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/problem:
|
||||
en: ac4fb08e3203cee9
|
||||
zh: 69fcfcb4f8fc0319
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/decision:
|
||||
en: 653c74442decb65a
|
||||
zh: 46f01c45d48b846b
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/alternatives-considered:
|
||||
en: 21a9a19b76f42758
|
||||
zh: 30a8f5425b59900d
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/consequences:
|
||||
en: b58904921286a1db
|
||||
zh: a696c2954d86c04f
|
||||
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/testing:
|
||||
en: 78253191eea198fa
|
||||
zh: 32bdd6807ddc13a0
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: A seen prompt marker keeps its own tail grace
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-pwsh-prompt-tail-grace.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Windows sessions on the self-hosted lane settled one or two of seven sends on the controlled prompt and charged the silence tier for the rest (2026-09-25/26, issue #2487). The exact readiness evidence is the controlled prompt's printable tail after the OSC `133;D` marker, and the bound on waiting for it was `idleSilenceMs + handoffGraceMs`. A native Windows probe shows the render arrives as two pty chunks: the marker alone, then the five-byte `dsh> ` 2-28 ms later (56 renders), with nothing printable after any prompt and no terminal queries in the output. The marker is written by the shell's own prompt function and the tail by the same render, so a tail that arrives past the bound means the host stalled that delivery, not that the prompt is absent. The session settled `inferred_idle` anyway, and each such send cost the tool call roughly three seconds.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-terminal-bash` gains a validated `promptTailGraceMs` configuration field, default `0`. While a send has seen the prompt marker and its printable tail has not arrived, the silence bound becomes `idleSilenceMs + handoffGraceMs + promptTailGraceMs`; every other bound, wait reason, and code path is unchanged. The [persistent PTY readiness design](../feature/2026-07-16-persistent-pty-sessions.md) owns the tier ladder this bound extends. Zero reproduces the previous bound exactly, so an upgrade changes no deployment's behaviour. The persistent pwsh Loader-composition case sets 5000 ms, because the hosts whose stalls the field is for are exactly the ones that run it, and its assertion (at least six `stdin_read` settlements, no `inferred_idle`) then measures the controlled-prompt path instead of the host's delivery timing. `validateConfig` accepts zero for this field — the one numeric bound whose zero is a documented value — and rejects negative or fractional values.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Raise the default instead of adding a field.** Rejected: it trades one fixed cost for another on every host. A marker whose tail never arrives (a stray marker from a child, a broken prompt function) would then fall back that much later everywhere, while the timing that matters differs per deployment.
|
||||
|
||||
**Make the observation sticky: once the exact tail was seen, let later output stand.** Rejected: the tail rule exists to prove the marker was followed by the prompt text rather than by a command's output, and a sticky flag would settle a send while that command is still printing.
|
||||
|
||||
**Wait for the tail up to the absolute `timeoutMs` once a marker was seen.** Rejected: a prompt that never completes its render would hang every send until the tool deadline instead of falling back after a bounded silence, and the send budget belongs to the caller.
|
||||
|
||||
**Detect the stall in the test and retry the send.** Rejected: retries hide which tier settled a send, and the durable fix belongs where the evidence is interpreted, not where it is measured.
|
||||
|
||||
## Consequences
|
||||
|
||||
A deployment whose console renderer stalls keeps those sends on the exact path by setting `promptTailGraceMs`; the default path is bit-for-bit the previous bound, so the negative control — the same delayed tail with the field at zero — still settles `inferred_idle`. The tolerance applies only after a marker was seen: a missing prompt keeps the plain silence bound, so the regression the Loader-composition case pins (a lost prompt must fail) is still caught, and a marker whose tail never arrives falls back one configured interval later.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/terminal/terminal-bash/tests/session.spec.ts` pins every state with fake timers: with `promptTailGraceMs` set, a marker whose tail arrives after the plain bound stays pending and then settles `stdin_read`; a marker whose tail never arrives falls back to `inferred_idle` at the extended bound; a tail that arrived and was invalidated by later output keeps the plain bound; and with the field at zero the same delayed delivery settles `inferred_idle`. `packages/terminal/terminal-bash/tests/config.spec.ts` accepts zero and rejects negative or fractional values, and rejects a nonzero tolerance shorter than one `pollIntervalMs`. `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` carries the end-to-end evidence on native Windows: with every session's prompt tail withheld for four seconds the case passes with the configured tolerance — nine settles, eight `stdin_read` and the `exit` command's `session_exit`, in 33.4 s and 37.5 s — and fails with the tolerance patched to zero, where two sends settle `inferred_idle` with `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3312-3316 ms.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 已看到的提示符标记保留自己的尾部宽限
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-pwsh-prompt-tail-grace.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
自托管 lane 上的 Windows 会话在七次 send 中只有一到两次按受控提示符结算,其余都要支付静默层(2026-09-25/26,issue #2487)。精确的就绪证据是 OSC `133;D` 标记之后受控提示符的可打印尾部,而等待它的上界是 `idleSilenceMs + handoffGraceMs`。原生 Windows 探测表明该渲染以两个 pty 分块到达:先是标记本身,2–28 毫秒后是 5 字节的 `dsh> `(56 次渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询。标记由 shell 自己的 prompt 函数写出、尾部由同一次渲染写出,因此尾部晚于上界到达意味着宿主机拖慢了这次投递,而不是提示符缺失。会话此前仍会按 `inferred_idle` 结算,每次这样的 send 让工具调用多付约三秒。
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-terminal-bash` 新增一个经过校验的 `promptTailGraceMs` 配置字段,默认 `0`。当某次 send 已看到提示符标记而其可打印尾部尚未到达时,静默上界变为 `idleSilenceMs + handoffGraceMs + promptTailGraceMs`;其他上界、等待原因与代码路径均不变。该上界所扩展的层级阶梯由[持久 PTY 就绪设计](../feature/2026-07-16-persistent-pty-sessions.zh.md)拥有。零精确复现此前的上界,因此升级不改变任何部署的行为。持久 pwsh 的 Loader 组合用例设置 5000 毫秒,因为需要该字段的宿主机正是运行该用例的那些,而其断言(至少六次 `stdin_read` 结算、不含 `inferred_idle`)由此度量受控提示符路径,而不是宿主机的投递时序。`validateConfig` 允许该字段取零——这是唯一一个零为文档化取值的数值上界——并拒绝负数与小数。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**不加字段,直接提高默认值。** 不采纳:那是把一种固定成本换成另一种,且对所有宿主机生效。尾部永不到达的标记(子进程打印的杂散标记、损坏的 prompt 函数)会在所有地方都多等那么久,而真正重要的时序因部署而异。
|
||||
|
||||
**让观测变为粘性:一旦看到精确尾部,后续输出不再使其失效。** 不采纳:尾部规则的存在就是为了证明标记之后跟着的是提示符文本而不是命令输出;粘性标志会在命令仍在打印时结算该 send。
|
||||
|
||||
**看到标记后一直等到绝对 `timeoutMs`。** 不采纳:渲染永不完成的提示符会让每次 send 都挂到工具截止时间,而不是在有界静默后回退,而 send 预算属于调用方。
|
||||
|
||||
**在测试里检测停摆并重试该 send。** 不采纳:重试会掩盖究竟是哪一层结算的,而持久的修复属于解释证据的地方,而不是测量证据的地方。
|
||||
|
||||
## Consequences
|
||||
|
||||
控制台渲染器会停摆的部署通过设置 `promptTailGraceMs` 让这些 send 留在精确路径上;默认路径与此前的上界逐位一致,因此负控——同样的尾部延迟、字段为零——仍然按 `inferred_idle` 结算。该宽限只在看到标记之后生效:提示符缺失时保持普通静默上界,所以 Loader 组合用例所固定的回归(提示符丢失必须失败)仍会被抓住,而尾部永不到达的标记只会晚一个配置区间回退。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/terminal/terminal-bash/tests/session.spec.ts` 用假定时器固定每一种状态:设置 `promptTailGraceMs` 时,尾部晚于普通上界到达的标记保持挂起,随后按 `stdin_read` 结算;尾部始终不到时在扩展上界处回落到 `inferred_idle`;尾部已到达但被后续输出失效时保持普通上界;字段为零时,同样的延迟投递按 `inferred_idle` 结算。`packages/terminal/terminal-bash/tests/config.spec.ts` 接受零、拒绝负数或小数,并拒绝非零但短于一个 `pollIntervalMs` 的宽限。`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 承载原生 Windows 上的端到端证据:把每个 session 的提示符尾部扣留四秒时,配置了该宽限后用例通过——九次结算、八次 `stdin_read` 加 `exit` 命令的 `session_exit`,耗时 33.4 秒与 37.5 秒;把该宽限改为零后同一停摆失败,其中两次 send 以 `inferred_idle` 结算,`promptSeen` 为 true、`promptTextSeen` 为 false、尾部为空且 `idleFor` 为 3312–3316 毫秒。
|
||||
@@ -12,8 +12,8 @@
|
||||
en: 532bb7268d9b93ce
|
||||
zh: ad47162233222ab0
|
||||
/agent-note-parallel-tool-call-execution-by-per-call-safety/scheduling-and-ordering:
|
||||
en: f0906d03fef98e7b
|
||||
zh: f9e43c4c832c9a05
|
||||
en: a485c530ea4366de
|
||||
zh: 6f9915d726de9ac7
|
||||
/agent-note-parallel-tool-call-execution-by-per-call-safety/safety-contract:
|
||||
en: 16c4ee08b65ead23
|
||||
zh: df80d6e49982615b
|
||||
@@ -24,8 +24,8 @@
|
||||
en: eb5a6aa84980f0ab
|
||||
zh: eed2680a160e8ed2
|
||||
/agent-note-parallel-tool-call-execution-by-per-call-safety/alternatives-considered:
|
||||
en: 44f8fb5851ba4e39
|
||||
zh: 4d75c937ac5277f7
|
||||
en: 17becc038bb7df7c
|
||||
zh: 673d9a9bdfa39edf
|
||||
/agent-note-parallel-tool-call-execution-by-per-call-safety/consequences:
|
||||
en: 5e5b2a1c7d850f83
|
||||
zh: b63311f28eb69366
|
||||
en: a5508057bc03f4f6
|
||||
zh: 96b46c59402474c3
|
||||
@@ -46,7 +46,7 @@ Only dispatch and the tool body overlap. `tools/pre-execute` and `tools/post-exe
|
||||
|
||||
Each started call appends `tool/call` immediately before its pre-execute gate. Completed dispatches occupy model-order slots, and a commit cursor appends `tool/result` and collects `additionalContexts` only when the next slot is ready. Live surfaces may show several pending calls, but results and post-tool context remain model-ordered.
|
||||
|
||||
An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. Because that failure is terminal internal state rather than a tool outcome, the loop does not invent tool results for rejected or uncommitted calls.
|
||||
An abort stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and records synthetic aborted call/result pairs for undispatched calls. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. The driver records missing results before closing the failed step under the [failed-step recovery decision](../bug-fix/2026-09-19-failed-step-tool-results.md).
|
||||
|
||||
PTC mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler.
|
||||
|
||||
@@ -84,7 +84,7 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over
|
||||
|
||||
**Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` leaves an insertion point for a policy hook.
|
||||
|
||||
**Convert scheduler failures into tool results.** AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRuntime owns body-invocation state and typed tool outcomes. Internal scheduler failures therefore remain terminal instead of being reclassified as `ABORTED` results.
|
||||
**Convert scheduler failures into ordinary tool results.** AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRuntime owns body-invocation state and typed tool outcomes. Internal scheduler failures remain terminal instead of being reclassified as `ABORTED` results. Explicit recovery results preserve this uncertainty without leaving unanswered calls.
|
||||
|
||||
**Start calls while the model streams.** This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete.
|
||||
|
||||
@@ -96,7 +96,7 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over
|
||||
|
||||
The design is fail-closed and simple for tool authors, but it cannot exploit concurrency whose safety depends on comparing siblings. A tool that opts in too broadly can expose latent shared-state races.
|
||||
|
||||
Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler therefore records only started calls, drains them on abort, and never starts replacements after cancellation.
|
||||
Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler drains them on abort and never starts replacements after cancellation; undispatched calls receive synthetic aborted call/result pairs.
|
||||
|
||||
Ordered commits may hold a fast result behind a slow earlier sibling. This preserves replay and model-history order while live surfaces still show pending progress.
|
||||
|
||||
@@ -104,4 +104,4 @@ Concurrent external calls can compete for quota or process capacity. Providers o
|
||||
|
||||
Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool.
|
||||
|
||||
A terminal scheduler failure may leave recorded calls without results before the failed step closes. Waiting for live dispatches preserves quiescence without misreporting those internal failures as tool outcomes.
|
||||
A terminal scheduler failure reaches step recovery only after live dispatches settle. Recovery reports uncommitted outcomes as unknown, preserving quiescence and the original failure while keeping later model history usable.
|
||||
@@ -46,7 +46,7 @@ Status: implemented
|
||||
|
||||
每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 `tool/call`。已完成的派发占据模型顺序的槽位;提交游标只有在下一个槽位就绪时,才会追加 `tool/result` 并收集 `additionalContexts`。实时界面可以显示多个待处理调用,但结果和工具执行后的上下文仍按模型顺序排列。
|
||||
|
||||
如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。由于该故障是内部终态,而非工具结果,循环不会为被拒绝或未提交的调用虚构工具结果。
|
||||
中止会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,并为未派发的调用记录合成的中止调用/结果对。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。驱动器按照[失败步骤恢复决策](../bug-fix/2026-09-19-failed-step-tool-results.zh.md),在关闭失败步骤之前记录缺失结果。
|
||||
|
||||
PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。
|
||||
|
||||
@@ -84,7 +84,7 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co
|
||||
|
||||
**公开分阶段方法或调度 waterfall(瀑布式事件)。** 公开的 `prepare` / `dispatch` / `finalize` 方法或 `tools/execution-mode` 事件,会在另一个消费方需要它之前扩大扩展接口。循环使用内部调度器视图,而 `executionMode(exec)` 为策略钩子保留了插入点。
|
||||
|
||||
**将调度器故障转换为工具结果。** AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRuntime 负责工具主体调用状态和类型化工具结果。因此,内部调度器故障保持为终态,而不会被重新分类为 `ABORTED` 结果。
|
||||
**将调度器故障转换为普通工具结果。** AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRuntime 负责工具主体调用状态和类型化工具结果。内部调度器故障保持为终态,而不会被重新分类为 `ABORTED` 结果。显式恢复结果保留这种不确定性,同时避免留下尚无结果的调用。
|
||||
|
||||
**在模型流式输出时启动调用。** 这可能进一步降低延迟,但会改变 assistant 消息的权威性、回放以及调用/结果配对。调度器只在 assistant 消息完成后才启动。
|
||||
|
||||
@@ -96,7 +96,7 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co
|
||||
|
||||
该设计遵循安全侧原则,对工具作者而言也很简单,但无法利用必须通过比较并列调用才能确认安全的并发性。工具过于宽泛地选择并行,可能暴露潜在的共享状态竞态。
|
||||
|
||||
在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。因此,调度器只记录已启动的调用,在中止时将其排空,且取消后绝不启动替换调用。
|
||||
在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。调度器在中止时将其排空,且取消后绝不启动替换调用;未派发的调用获得合成的中止调用/结果对。
|
||||
|
||||
有序提交可能会让快速结果等待较慢的早期并列调用。这保留了回放和模型历史顺序,同时实时界面仍可显示待处理进度。
|
||||
|
||||
@@ -104,4 +104,4 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co
|
||||
|
||||
工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。
|
||||
|
||||
终态调度器故障可能会在故障步骤关闭前留下已记录但没有结果的调用。等待仍在运行的派发可确保完全停稳,而不会将这些内部故障误报为工具结果。
|
||||
终态调度器故障只有在运行中的派发结算后才会进入步骤恢复。恢复将未提交的执行结果报告为未知,保留完全停稳与原始失败,同时让后续模型历史保持可用。
|
||||
@@ -24,8 +24,8 @@
|
||||
en: b92715f36550c728
|
||||
zh: e18d7e0e2363347a
|
||||
/agent-note-persistent-pty-sessions/decision/local-readiness-detection:
|
||||
en: cd8b4032a624c277
|
||||
zh: 47f0d8c15f1c9cad
|
||||
en: 8f36f06869bd5367
|
||||
zh: f3560477a8965cbe
|
||||
/agent-note-persistent-pty-sessions/decision/model-visible-output-and-durability:
|
||||
en: 1b504ab41b47216d
|
||||
zh: 8fb3d360b3a4e6a1
|
||||
|
||||
@@ -72,13 +72,13 @@ With `run_in_background: true`, `dsh-tool-terminal` registers the in-flight send
|
||||
|
||||
### Local readiness detection
|
||||
|
||||
The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`.
|
||||
The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, `promptTailGraceMs`, and `timeoutMs`.
|
||||
|
||||
On Linux, the inspector reads the shell's terminal foreground PGID from `/proc/<shellPid>/stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. The waiting thread's `/proc/<pid>/task/<tid>/fd/0` must identify the shell's controlling terminal device, so a thread-local fd table cannot substitute its leader's terminal descriptor and a pipeline reader blocked on its pipe remains a running command. Direct PTY descriptors use their device number; `/dev/tty` uses the owning process's `tty_nr` because `stat` reports the alias device rather than the selected PTY. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. A host policy that denies `/proc/<pid>/task/<tid>/syscall`, including hardened ptrace policy, likewise skips Tier 1 and preserves the bounded Tier 2 idle inference; process sleep state never substitutes for inaccessible syscall evidence. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; the inspector admits the runtime architecture, then checks every supported kernel ABI because `/proc` can report a different ABI under user-mode emulation. Unsupported runtime architectures skip Tier 1.
|
||||
|
||||
On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path.
|
||||
|
||||
Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. When a prompt marker was already seen, Tier 2 waits a further `handoffGraceMs` so a bash foreground handoff that lands on the silence boundary still settles as the exact `stdin_read` attribution instead of the weaker inference; the grace is a deployment-owned config field validated to cover at least one `pollIntervalMs`, because a grace shorter than the poll period cannot contain a single readiness poll and so cannot change any outcome. It bounds only sends that saw a marker, so its cost is the interactive return latency of that one case rather than every send. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.jobs`, signal the foreground group, or inspect from another session.
|
||||
Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. When a prompt marker was already seen, Tier 2 waits a further `handoffGraceMs` so a bash foreground handoff that lands on the silence boundary still settles as the exact `stdin_read` attribution instead of the weaker inference; the grace is a deployment-owned config field validated to cover at least one `pollIntervalMs`, because a grace shorter than the poll period cannot contain a single readiness poll and so cannot change any outcome. A marker whose printable tail has not arrived yet adds `promptTailGraceMs` to that bound, because the marker and its tail are written by the same prompt render and a contended host delays only the tail; the [prompt tail grace decision](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md) owns the field, and it applies only while the tail can still complete. It bounds only sends that saw a marker, so its cost is the interactive return latency of that one case rather than every send. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.jobs`, signal the foreground group, or inspect from another session.
|
||||
|
||||
Once a send settles under any tier, `TerminalSendOperation.append` stops accepting output, so later child output no longer reaches that settled operation; it still reaches the scrollback, and any send that is active when it arrives. A test that waits for a marker on the operation it started must therefore set `idleSilenceMs` and `timeoutMs` above the child's own startup latency; interpreter startup on a loaded macOS runner otherwise ends the send before the marker is printed.
|
||||
|
||||
|
||||
@@ -72,13 +72,13 @@ UI 渲染约定精确且不携带位置信息。`terminal_send` 只为前台发
|
||||
|
||||
### 本地就绪检测
|
||||
|
||||
本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。
|
||||
本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs`、`promptTailGraceMs` 和 `timeoutMs`。
|
||||
|
||||
在 Linux 上,检查器从 `/proc/<shellPid>/stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。等待线程的 `/proc/<pid>/task/<tid>/fd/0` 还必须标识 shell 的控制终端设备,因此线程本地 fd 表无法用 leader 的终端描述符冒充自身 fd,阻塞于管道的流水线读取端仍属于正在运行的命令。直接 PTY 描述符使用其设备号;`/dev/tty` 则使用所属进程的 `tty_nr`,因为 `stat` 报告的是别名设备而非选定的 PTY。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。宿主策略(包括加固的 ptrace 策略)若拒绝读取 `/proc/<pid>/task/<tid>/syscall`,同样会跳过 Tier 1 并保留有界的 Tier 2 idle 推断;进程休眠状态绝不会代替不可访问的 syscall 证据。架构表只包含对应 Linux UAPI 定义的 syscall number;检查器先准入运行时架构,再检查每个受支持的内核 ABI,因为在用户态模拟下,`/proc` 可能报告不同的 ABI。不受支持的运行时架构会跳过 Tier 1。
|
||||
|
||||
macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入,并在 Linux 上经过单元测试,同时由 macOS CI job 驱动真实 PTY 和进程表路径。
|
||||
|
||||
Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。如果此前已经见过 prompt marker,Tier 2 会再等待 `handoffGraceMs`,使恰好落在静默边界上的 bash 前台交接仍然以精确的 `stdin_read` 归因结束,而不是退到较弱的推断;该宽限是由部署方拥有的配置字段,并被校验为至少覆盖一个 `pollIntervalMs`——短于轮询周期的宽限装不下一次就绪轮询,因此不可能改变任何结果。它只约束见过 marker 的 send,代价是这一种情况的交互返回延迟,而不是每一次 send。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.jobs` 等待、向前台组发信号,或从另一个会话排查。
|
||||
Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。如果此前已经见过 prompt marker,Tier 2 会再等待 `handoffGraceMs`,使恰好落在静默边界上的 bash 前台交接仍然以精确的 `stdin_read` 归因结束,而不是退到较弱的推断;该宽限是由部署方拥有的配置字段,并被校验为至少覆盖一个 `pollIntervalMs`——短于轮询周期的宽限装不下一次就绪轮询,因此不可能改变任何结果。若已见 marker 而其可打印尾部尚未到达,该上界还会加上 `promptTailGraceMs`:marker 与尾部由同一次提示符渲染写出,负载高的宿主机只会拖慢尾部;该字段由[提示符尾部宽限决策](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)拥有,且只在尾部仍可能补齐时生效。它只约束见过 marker 的 send,代价是这一种情况的交互返回延迟,而不是每一次 send。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.jobs` 等待、向前台组发信号,或从另一个会话排查。
|
||||
|
||||
一次 send 在任一层级 settle 之后,`TerminalSendOperation.append` 就不再接受输出,此后子进程的输出不会再进入那个已 settle 的 operation;它仍然会进入 scrollback,以及此时恰好处于活跃状态的任何 send。因此,等待自己所启动的 operation 上出现标记的测试,必须把 `idleSilenceMs` 与 `timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。
|
||||
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: d3383b495424d118
|
||||
zh: 4e6ffc8ebfa7f741
|
||||
/agent-note-session-telemetry-seam-with-mandatory-redaction-and-the-otel-backend/decision:
|
||||
en: 85b950a67315023c
|
||||
zh: cb4c08338c30b226
|
||||
en: 739b871ae583dcbf
|
||||
zh: d17053e3b0a35942
|
||||
/agent-note-session-telemetry-seam-with-mandatory-redaction-and-the-otel-backend/alternatives-considered:
|
||||
en: 01838eefca2bc117
|
||||
zh: fc71f5449d095b27
|
||||
|
||||
@@ -16,6 +16,8 @@ Every deployment that wants harness sessions in an observability stack must hand
|
||||
- **The `session-telemetry/record` waterfall** — the delta over the branch version and the seam's redaction extension point. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten.
|
||||
- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `DISABLED` is the plugin default and constructs no transport. `FEEDBACK_ONLY` requires `exporter.url`; the [explicit-feedback policy](../architecture/2026-09-05-nonofficial-feedback-otel.md) requires a new submission for every bounded capture, for all providers. [Buffer-free feedback replay](../../archived/simplification/2026-08-06-buffer-free-feedback-telemetry.md) avoids a second in-memory copy of the session prefix.
|
||||
|
||||
The [Session-log request limits](../architecture/2026-09-25-session-log-otel-byte-limits.md) supersede the SDK-only batching rule below for the OTel Session sender; capture, redaction, and handoff rationale remain applicable.
|
||||
|
||||
The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs. Delivery is best-effort: a crash can lose queued records. Backend retry or losing the same live object's module-scope cursor can duplicate lifecycle-local rows, so receivers deduplicate on `(session.id, session.format_version, event.seq)`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -16,6 +16,8 @@ Status: implemented
|
||||
- **`session-telemetry/record` waterfall(瀑布式事件)** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何后端前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。
|
||||
- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考后端:OTel JS SDK 日志流水线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`DISABLED` 是插件默认值,且不构造任何传输。`FEEDBACK_ONLY` 要求 `exporter.url`;[显式反馈策略](../architecture/2026-09-05-nonofficial-feedback-otel.zh.md)要求每次有界捕获都由新提交触发,适用于所有提供方。[无缓冲反馈回放](../../archived/simplification/2026-08-06-buffer-free-feedback-telemetry.md)避免在内存中创建会话前缀的第二份副本。
|
||||
|
||||
[Session 日志请求上限](../architecture/2026-09-25-session-log-otel-byte-limits.zh.md) 在 OTel Session 发送器中替代下述仅由 SDK 分批的规则;采集、脱敏和交接的理由仍然适用。
|
||||
|
||||
边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,并经 passthrough 配置。投递是尽力而为:崩溃可能丢失已排队记录。后端重试或丢失同一 live 对象的模块作用域游标可能重复生命周期本地 row,因此接收端基于 `(session.id, session.format_version, event.seq)` 去重。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
en: 8892eea7f62d45ed
|
||||
zh: 18f64966ff53e38b
|
||||
/agent-note-web-subagent-catalog-and-human-continuation/design-context:
|
||||
en: 3c44fc5af90bc807
|
||||
zh: 478538d028a52858
|
||||
en: da3858c86cf685ff
|
||||
zh: b13ee4f45e3dff7d
|
||||
/agent-note-web-subagent-catalog-and-human-continuation/product-contract:
|
||||
en: 2060d607d1cd1ef3
|
||||
zh: e7100887358625c4
|
||||
|
||||
@@ -28,7 +28,7 @@ This decision covers Web discovery, transcript viewing, and parent-authorized hu
|
||||
|
||||
## Design context
|
||||
|
||||
The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-14602&p=f), [hierarchical expansion](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-15917&p=f), and [child conversation](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=388-18584&p=f) frames are non-normative interaction and visual references. This note owns lifecycle, wire, and failure semantics.
|
||||
The Figma subagent list, hierarchical expansion, and child conversation frames are non-normative interaction and visual references. This note owns lifecycle, wire, and failure semantics.
|
||||
|
||||
| Design intent | Shipped contract |
|
||||
| --- | --- |
|
||||
|
||||
@@ -28,7 +28,7 @@ Web 产品通过页头的当前 title 谱系区域公开选中会话中由会话
|
||||
|
||||
## 设计上下文
|
||||
|
||||
Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-14602&p=f)、[层级展开](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-15917&p=f)与 [child 对话](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=388-18584&p=f)画框是非规范性的交互与视觉参考。本记录负责生命周期、协议与失败语义。
|
||||
Figma 中的 subagent 列表、层级展开与 child 对话画框是非规范性的交互与视觉参考。本记录负责生命周期、协议与失败语义。
|
||||
|
||||
| 设计意图 | 已交付约定 |
|
||||
| --- | --- |
|
||||
|
||||
+4
-4
@@ -9,11 +9,11 @@
|
||||
en: 4fb1c27a631b32d3
|
||||
zh: 9dbe9a4b9aadfca9
|
||||
/agent-note-serial-cross-platform-ci-reference/decision:
|
||||
en: f547014d316c1385
|
||||
zh: f13b09a4a778adaa
|
||||
en: 7c58d88d4c002f19
|
||||
zh: a3b961be49f2a96b
|
||||
/agent-note-serial-cross-platform-ci-reference/alternatives-considered:
|
||||
en: bc72a1e194e3b0e2
|
||||
zh: 9a92cbac88adc7a7
|
||||
en: 3610152c5c4e93af
|
||||
zh: f4c09d327716f750
|
||||
/agent-note-serial-cross-platform-ci-reference/consequences:
|
||||
en: 7042125ce6a209d4
|
||||
zh: ef2be4fa8a4d6320
|
||||
@@ -24,7 +24,7 @@ Platform ownership remains explicit inside that complete aggregate. `terminal-ba
|
||||
|
||||
The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install.
|
||||
|
||||
The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge.
|
||||
The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. A fifth job, `unit-darwin`, runs the complete unit suite on `macos-latest` under its own budget, so the darwin-only unit expectations keep a platform oracle without holding the Seatbelt leg to the unit suite's duration. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge.
|
||||
|
||||
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The ci-master and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
|
||||
|
||||
@@ -35,7 +35,7 @@ The active serial references run on the self-hosted `vm-backup` (`serial / linux
|
||||
- **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression.
|
||||
- **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check.
|
||||
- **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and native Windows checks, and the other native jobs supply the complete Windows result.
|
||||
- **Run the real-kernel Sandbox matrix on every pull request** - rejected because its four statuses do not participate in branch protection, while repeated installs, Landlock builds, and macOS unit parity consume runner capacity without changing the merge verdict. The master run retains the platform and installed-launcher signal.
|
||||
- **Run the real-kernel Sandbox matrix on every pull request** - rejected because its statuses do not participate in branch protection, while repeated installs, Landlock builds, and macOS unit parity consume runner capacity without changing the merge verdict. The master run retains the platform and installed-launcher signal.
|
||||
- **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism.
|
||||
- **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs.
|
||||
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ Status: implemented
|
||||
|
||||
macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也涵盖从交互式子进程继承而来的标记。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。
|
||||
|
||||
独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。
|
||||
独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。第五个作业 `unit-darwin` 在 `macos-latest` 上以自己的预算运行完整单元测试套件,让只在 darwin 上生效的单元测试预期保有平台判据,同时不让 Seatbelt 作业受制于单元测试套件的时长。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。
|
||||
|
||||
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。ci-master 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
|
||||
|
||||
@@ -35,7 +35,7 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的
|
||||
- **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。
|
||||
- **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。
|
||||
- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和原生 Windows 检查,而其它原生作业提供完整的 Windows 结果。
|
||||
- **在每个拉取请求上运行真实内核 Sandbox 矩阵**:不予采纳,因为它的四个状态不参与分支保护,而重复安装、Landlock 构建以及为保持平台一致而运行的 macOS 单元测试会消耗运行器容量,却不会改变合并裁决。master 上的运行保留平台与已安装 launcher 的信号。
|
||||
- **在每个拉取请求上运行真实内核 Sandbox 矩阵**:不予采纳,因为它的状态不参与分支保护,而重复安装、Landlock 构建以及为保持平台一致而运行的 macOS 单元测试会消耗运行器容量,却不会改变合并裁决。master 上的运行保留平台与已安装 launcher 的信号。
|
||||
- **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。
|
||||
- **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: cef0f05fd8dfd6a1
|
||||
zh: 228107eb6c00a91a
|
||||
/agent-note-freeze-low-future-value-agent-notes-outside-the-active-corpus/decision:
|
||||
en: 453cf66046816477
|
||||
zh: af5bb77e849416a1
|
||||
en: f0d9c292c3b8dd3b
|
||||
zh: fd928084dfd333e2
|
||||
/agent-note-freeze-low-future-value-agent-notes-outside-the-active-corpus/alternatives-considered:
|
||||
en: 7637deef67f3ec14
|
||||
zh: 7f43606e487fab67
|
||||
|
||||
@@ -18,7 +18,7 @@ The root `.rgignore` excludes the archive from searches that traverse a parent d
|
||||
|
||||
After archival, the triplet is permanently frozen and is historical context rather than current authority. It is not updated for renamed packages, changed behavior, translation standards, formatting rules, broken outbound links, or later documentation contracts. Active prose may intentionally link into an archived note, redirect that link to current authority, or delete it. Repository gates therefore validate links into archived files but never treat archived files as link sources.
|
||||
|
||||
[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) owns the frozen boundary. It accepts only the closed set of Agent Note kinds, requires a complete triplet with implemented status and matching valid archive dates, verifies the sidecar against both current Git blob hashes, and seals every artifact by path and SHA-256 content hash in an append-only manifest. Its `--write` mode first proves every existing seal unchanged and then appends only newly archived artifacts. Pull-request CI supplies the trusted base SHA and checks out complete history before running the verifier, so a reused runner's shallow checkout cannot omit the baseline manifest. The ordinary Agent Note format, translation-pairing, wrapping, Markdown-link, package-path, Mermaid, documentation-TypeScript, and type-equivalence gates exclude archive sources; their evolving standards cannot create pressure to edit history.
|
||||
[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) owns the frozen boundary. It accepts only the closed set of Agent Note kinds, requires a complete triplet with implemented status and matching valid archive dates, verifies the sidecar against both current Git blob hashes, and seals every artifact by path and SHA-256 content hash in an append-only manifest, with only the exact seal-transition exceptions recorded in [archive instructions](../../archived/AGENTS.md). Its `--write` mode first proves every existing seal unchanged and then appends only newly archived artifacts. Pull-request CI supplies the trusted base SHA and checks out complete history before running the verifier, so a reused runner's shallow checkout cannot omit the baseline manifest. The ordinary Agent Note format, translation-pairing, wrapping, Markdown-link, package-path, Mermaid, documentation-TypeScript, and type-equivalence gates exclude archive sources; their evolving standards cannot create pressure to edit history.
|
||||
|
||||
The [`dsh-archive-agent-notes`](../../../skills/dsh-archive-agent-notes/SKILL.md) workflow owns classification. It requires a semantic note-by-note audit, uses code and current documentation to identify present authority, treats word count only as triage, carries calibrated keep/archive/delete examples, and reports genuinely borderline outcomes for review.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录
|
||||
|
||||
归档后,这三个文件永久冻结,只作为历史背景,不再是当前权威依据。不得因为包重命名、行为变化、翻译标准、格式规则、出站链接失效或后续文档约定而更新归档文件。活跃文档可以有意链接到归档 Agent Note,也可以把该链接重定向到当前权威依据,或直接删除。仓库门禁因此会校验指向归档文件的链接,但绝不把归档文件作为链接源来校验。
|
||||
|
||||
[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) 负责维护冻结边界。它只接受封闭集合中的 Agent Note 类别,要求三个配对文件完整、状态为 implemented,且归档日期有效并互相匹配;它还会用双方当前的 Git blob hash 校验伴随记录,并在仅追加的 manifest(元数据清单)中按路径和 SHA-256 内容 hash 封存每项产物。其 `--write` 模式会先证明每条现有封存记录对应的内容都未改变,再仅追加新归档的产物。拉取请求 CI 会提供可信的基准 SHA,并在运行校验器前检出完整历史,因此复用运行器上的浅克隆检出无法漏掉基线 manifest。普通的 Agent Note 格式、翻译配对、换行、Markdown 链接、包路径、Mermaid、文档 TypeScript 和类型等价门禁都排除归档源文件,因此这些门禁持续演进的标准不会产生修改历史记录的压力。
|
||||
[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) 负责维护冻结边界。它只接受封闭集合中的 Agent Note 类别,要求三个配对文件完整、状态为 implemented,且归档日期有效并互相匹配;它还会用双方当前的 Git blob hash 校验伴随记录,并在仅追加的 manifest(元数据清单)中按路径和 SHA-256 内容 hash 封存每项产物,仅允许[归档说明](../../archived/AGENTS.md)中记录的精确封存哈希转换例外。其 `--write` 模式会先证明每条现有封存记录对应的内容都未改变,再仅追加新归档的产物。拉取请求 CI 会提供可信的基准 SHA,并在运行校验器前检出完整历史,因此复用运行器上的浅克隆检出无法漏掉基线 manifest。普通的 Agent Note 格式、翻译配对、换行、Markdown 链接、包路径、Mermaid、文档 TypeScript 和类型等价门禁都排除归档源文件,因此这些门禁持续演进的标准不会产生修改历史记录的压力。
|
||||
|
||||
[`dsh-archive-agent-notes`](../../../skills/dsh-archive-agent-notes/SKILL.md) 工作流负责分类判断。它要求逐份 Agent Note 做语义审计,使用代码和当前文档识别现行权威依据,仅把字数作为初步筛选手段,收录经过校准的保留、归档和删除示例,并报告真正处于边界的结果,以供评审。
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: b3ec2245644b46e6
|
||||
zh: 83de1cb620f9ddcf
|
||||
/agent-note-make-lefthook-installation-worktree-local/decision:
|
||||
en: 577d90ff7e03ebaf
|
||||
zh: dc680155c0c3a2a1
|
||||
en: b53a2425f88a6031
|
||||
zh: 41f459b00803b3f9
|
||||
/agent-note-make-lefthook-installation-worktree-local/alternatives-considered:
|
||||
en: d7100b0d2c95a3f9
|
||||
zh: 070c38c4d0d1331a
|
||||
|
||||
@@ -14,7 +14,7 @@ Lefthook-generated hooks prefer an absolute binary path captured from the instal
|
||||
|
||||
Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer so `git config --show-scope` can report which scope supplied a value, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`.
|
||||
|
||||
Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking.
|
||||
Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking. On Windows, acquiring or inspecting a lock retries EPERM within the existing 30-second acquisition deadline because deleting a lock can temporarily deny access while readers close their handles; other errors and expired deadlines still fail.
|
||||
|
||||
Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Git seeds a new linked worktree's `config.worktree` from the main worktree; when that seed contains the marker-backed reserved hook path of a registered worktree, the installer replaces only the new worktree's config with its own path. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝
|
||||
|
||||
钩子安装以 worktree 为作用域。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,安装程序要求 Git 2.26 或更高版本,使 `git config --show-scope` 可以报告由哪个作用域提供配置值;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。
|
||||
|
||||
升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空休眠 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须人工介入恢复,而不能自动强制解除。
|
||||
升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空休眠 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须人工介入恢复,而不能自动强制解除。在 Windows 上,获取或检查锁时遇到 EPERM 会在原有的 30 秒获取期限内重试,因为删除锁时,读取者关闭句柄前可能暂时无法访问该文件;其他错误及超时仍会导致失败。
|
||||
|
||||
每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Git 会以主 worktree 的配置为新链接 worktree 初始化 `config.worktree`;当该初始配置包含某个已注册 worktree 中由所有权标记佐证的保留钩子路径时,安装程序只会在新 worktree 的配置中将其替换为新 worktree 自有的路径。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。
|
||||
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-26-web-conversation-clock-fixture-day.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.md
|
||||
/agent-note-web-conversation-clocks-on-the-fixture-day:
|
||||
en: 13b5d9d0bb3626af
|
||||
zh: 0bd01e1848284a35
|
||||
/agent-note-web-conversation-clocks-on-the-fixture-day/problem:
|
||||
en: 6f334488a6d3a28d
|
||||
zh: b33aed9b10d0cd84
|
||||
/agent-note-web-conversation-clocks-on-the-fixture-day/decision:
|
||||
en: ba875c82795d58e1
|
||||
zh: 13a7df6f8c4e0aa6
|
||||
/agent-note-web-conversation-clocks-on-the-fixture-day/alternatives-considered:
|
||||
en: 9c0a2afb1473c1d1
|
||||
zh: 36c9e72a70072f5e
|
||||
/agent-note-web-conversation-clocks-on-the-fixture-day/verification:
|
||||
en: 5031ca866fed24dd
|
||||
zh: c11f71af70c1f5ec
|
||||
/agent-note-web-conversation-clocks-on-the-fixture-day/consequences:
|
||||
en: c5cdbbd3b2889df7
|
||||
zh: 8458ef92443cf1a6
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: Web conversation clocks on the fixture day
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-26-web-conversation-clock-fixture-day.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Run 36157253018 (job 108144715986, `node 24 / snapshots and artifacts`) failed four browser scenarios in one pass — [steering](../../../../apps/web/tests/steering.e2e.ts), [markdown-images](../../../../apps/web/tests/markdown-images.e2e.ts), [message-actions](../../../../apps/web/tests/message-actions.e2e.ts), and [reference-composer](../../../../apps/web/tests/reference-composer.e2e.ts) — with one aria difference: a message rendered `… and stop. 9/25 23:58` where its golden records `… and stop. {{clock}}`.
|
||||
|
||||
[`newEnglishPage`](../../../../apps/web/tests/support.ts) pins the browser to `Asia/Shanghai`, and [`formatMessageClock`](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) prefixes `clock.md` / `clock.ymd` to the clock as soon as the message's local calendar day or year differs from the renderer's. These scenarios left both clocks on the wall clock, and their message times are stamped during the run: a live composer send by the Host, a seed whose fixture header carries `createdAt: 0` anchored at `Date.now() - 60_000` by `seedSession`. The failing job started at 15:54Z, six minutes before local midnight, so rows stamped before it were captured after it and rendered a date prefix; the next run of the same scenario passed minutes after midnight.
|
||||
|
||||
The lane already carried the remedy — [`WEB_FIXTURE_TIME`](../../../../apps/web/tests/support.ts), documented as the same-day anchor for seeded event times and the `Asia/Shanghai` browser clock, and used by seven other scenarios — but these four scenarios never adopted it.
|
||||
|
||||
## Decision
|
||||
|
||||
The four scenarios read the fixture day on both clocks.
|
||||
|
||||
Live-stamped scenarios pin the Host clock with `pinHostClock()`, a `Date.now` spy advancing from the anchor with real elapsed time, and the browser clock with [`pinBrowserClock(page)`](../../../../apps/web/tests/support.ts). Seeded scenarios anchor the seed through `seedSession`'s `createdAt` argument and pin the browser clock the same way.
|
||||
|
||||
The seed anchor is not only the rendered clock: the sidebar's relative ages are computed against the browser clock, so a pinned browser with a live-anchored seed reads `8mo` where the [message-actions](../../../../apps/web/tests/message-actions.e2e.ts) fork golden asserts `now` and `1min`.
|
||||
|
||||
`pinBrowserClock` re-applies one frozen instant from the anchor on an interval instead of leaving it at a single value, because product code reads two `Date.now()` values to decide how long a rendered value is held: the step process keeps its previous title for `PROCESS_TITLE_MINIMUM_MS` = 150 ms ([ChatGroupSeat](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx)) and the composer keymap keeps a composition guard 10 ms past `compositionend` ([keymap](../../../../packages/client/ui-conversation/src/client/input/editor/keymap.ts)). Both compute the remaining hold as `Date.now() - previousReading`, which a permanently frozen clock never advances, so the hold never elapses and the golden's settled text stays unrendered. CI run 36179748541 (job 108218939163) caught exactly that on the first revision that froze the clock: `Preparing questions` where the steering mid golden records `Waiting for your action · Ready to continue?`.
|
||||
|
||||
The steering mid capture waits for that settled question title before it captures, and the [whole-queue scenario](../../../../apps/web/tests/steering.e2e.ts) calls the queue gesture only after the running turn's Stop control appears, so Enter's queue decision always resolves against an agent that already reports busy.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Collapse the rendered date prefix in the aria normalizer.** Collapsing `9/25 23:58` to `{{clock}}` in `normalizeAria` fixes the four scenarios with a two-line change, and it was measured: it passed the four files and then failed `cordis-tool-round`, `stats-paged-history`, and `navigation-panes` in one `DSH_WEB_SNAPSHOT_WORKERS=16` lane run, because their goldens deliberately record the dated form (`9/1 {{clock}}` and the year form in the Timing pane), and `skill-tool-row` already tokenizes that form as `{{date}} {{clock}}`. The two renderings state different facts — this row is from another day, this row is from today — so collapsing them deletes assertions instead of stabilizing them.
|
||||
|
||||
**Leave the browser clock frozen.** Pinning one instant for the whole scenario is simpler and passed the four files, but it removes elapsed time from every two-reading hold in the page, which is how the `Preparing questions` capture above happened. Re-applying the anchor keeps the calendar pinned without changing how long a held value is held.
|
||||
|
||||
**Pin the clocks for the whole lane.** Pinning inside `launchWebScaffold` and `newEnglishPage` would cover every scenario at once, including the ones that fail this way in future. It loses on blast radius: 83 goldens carry a clock and session-tree goldens assert literal relative ages, so a lane-wide now moves every scenario away from its own session and fixture times simultaneously, and each would still need its own anchor to read the buckets it was recorded with.
|
||||
|
||||
**Keep scenarios clear of local midnight.** Scheduling or refusing runs near the boundary leaves the dependency in place and fails the run instead of the code under test. The [CI readiness and completion decision](2026-09-08-ci-readiness-and-completion.md) already rejects budgets and retries as substitutes for a controlled observation.
|
||||
|
||||
## Verification
|
||||
|
||||
Two negative controls pin one clock and leave the other on the wall clock, reproducing the recorded signature exactly: `steering` with the browser clock pinned and the Host clock live fails its two golden tests with `… and stop. 9/26 {{clock}}`, and `message-actions` with the browser clock pinned and a live-anchored seed fails with `9/26 {{clock}}` on every clock line plus `now` where the fork golden asserts `1min`. Pinning both clocks passes both files, and their goldens are unchanged.
|
||||
|
||||
The queue scenario's start race is measured, not inferred: with the clocks pinned and no busy observation it failed 10 times in 61 runs of `vitest run … -t "queues two messages"`, all at the same `2 queued messages` wait; `origin/master` without the pins failed 0 of 41; with the observation added, 20 of 20 passed.
|
||||
|
||||
CI run 36179748541 (job 108218939163) shows the calendar fix holding under the lane's own topology: the prompt line matches `{{clock}}` where run 36157253018 rendered `9/25 {{clock}}`.
|
||||
|
||||
## Consequences
|
||||
|
||||
The four scenarios render the same aria line whenever they run, and their goldens keep asserting a clock without a date prefix. The calendar fact is owned per scenario: a scenario that types messages, or seeds a fixture whose header carries `createdAt: 0`, must read the fixture day on both clocks, or it inherits the midnight exposure. Product clock rendering, the `Asia/Shanghai` browser timezone, every committed golden, and the assertions of the scenarios not listed here are unchanged.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Agent Note: Web conversation clocks on the fixture day
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-26-web-conversation-clock-fixture-day.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
运行 36157253018(作业 108144715986,`node 24 / snapshots and artifacts`)在一次执行中让四个浏览器场景失败——[steering](../../../../apps/web/tests/steering.e2e.ts)、[markdown-images](../../../../apps/web/tests/markdown-images.e2e.ts)、[message-actions](../../../../apps/web/tests/message-actions.e2e.ts) 与 [reference-composer](../../../../apps/web/tests/reference-composer.e2e.ts)——且是同一处 aria 差异:消息渲染为 `… and stop. 9/25 23:58`,而 golden 记录为 `… and stop. {{clock}}`。
|
||||
|
||||
[`newEnglishPage`](../../../../apps/web/tests/support.ts) 将浏览器时区固定为 `Asia/Shanghai`,而 [`formatMessageClock`](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) 一旦消息的本地日历日或年份与渲染时不同,就会在时钟前拼接 `clock.md` / `clock.ymd`。这些场景把两个时钟都留在墙上时钟上,而它们的消息时间是在运行期盖章的:实时输入由 Host 盖章,fixture 头部为 `createdAt: 0` 的种子由 `seedSession` 以 `Date.now() - 60_000` 为锚点。失败的作业于 15:54Z 启动,距本地午夜还有六分钟,于是午夜前盖章的行在午夜后捕获并渲染出日期前缀;同一场景的下一次运行在午夜后数分钟通过。
|
||||
|
||||
通道本已具备对策——[`WEB_FIXTURE_TIME`](../../../../apps/web/tests/support.ts),其文档写明它是种子事件时间与 `Asia/Shanghai` 浏览器时钟的同日锚点,另有七个场景在使用它——但这四个场景从未采用。
|
||||
|
||||
## Decision
|
||||
|
||||
这四个场景让两个时钟都读取 fixture 日。
|
||||
|
||||
实时盖章的场景用 `pinHostClock()` 固定 Host 时钟(一个从锚点起按真实流逝时间推进的 `Date.now` spy),并用 [`pinBrowserClock(page)`](../../../../apps/web/tests/support.ts) 固定浏览器时钟。种子场景通过 `seedSession` 的 `createdAt` 参数锚定种子,并用同样方式固定浏览器时钟。
|
||||
|
||||
种子锚点不只影响渲染出的时钟:侧边栏的相对时间也以浏览器时钟为基准计算,因此固定浏览器却用实时锚点的种子会读出 `8mo`,而 [message-actions](../../../../apps/web/tests/message-actions.e2e.ts) 的 fork golden 断言的是 `now` 与 `1min`。
|
||||
|
||||
`pinBrowserClock` 按固定间隔从锚点重新施加同一个冻结时刻,而不是让时钟停在单一数值上,因为产品代码要靠读取两个 `Date.now()` 值来决定一个已渲染值要保持多久:步骤进程会把上一个标题保持 `PROCESS_TITLE_MINIMUM_MS` = 150 ms([ChatGroupSeat](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx)),输入框 keymap 会在 `compositionend` 后再保持 10 ms 的合成守卫([keymap](../../../../packages/client/ui-conversation/src/client/input/editor/keymap.ts))。两者都把剩余保持时间算作 `Date.now() - previousReading`,而永久冻结的时钟永远不会让它推进,于是保持期不会结束,golden 里那行稳定文案始终不出现。CI 运行 36179748541(作业 108218939163)在第一个冻结时钟的修订上正是抓到了这一点:steering 的 mid golden 记录的是 `Waiting for your action · Ready to continue?`,实际捕获到 `Preparing questions`。
|
||||
|
||||
steering 的 mid 捕获在截图前等待该稳定标题出现;[整队场景](../../../../apps/web/tests/steering.e2e.ts) 则只在运行中回合的停止控件出现后才做排队手势,因此 Enter 的排队判定总是相对一个已上报忙碌的 agent 解析。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 aria normalizer 中折叠渲染出的日期前缀。** 在 `normalizeAria` 中把 `9/25 23:58` 折叠为 `{{clock}}` 只需两行即可修好这四个场景,且已实测:它让四个文件通过,随后在一次 `DSH_WEB_SNAPSHOT_WORKERS=16` 的通道运行中让 `cordis-tool-round`、`stats-paged-history` 与 `navigation-panes` 失败,因为这些 golden 有意记录带日期的形式(`9/1 {{clock}}`,以及 Timing 面板中的年份形式),而 `skill-tool-row` 早已把该形式 token 化为 `{{date}} {{clock}}`。两种渲染陈述的是不同事实——此行来自另一天,此行来自今天——因此折叠它们是在删除断言,而不是在稳定断言。
|
||||
|
||||
**让浏览器时钟保持冻结。** 整个场景固定单一时刻更简单,也能让四个文件通过,但它抽掉了页面中所有“读两次”保持逻辑的时间流逝,上面的 `Preparing questions` 捕获就是这样发生的。重新施加锚点既保持日历固定,又不改变已渲染值的保持时长。
|
||||
|
||||
**为整条通道固定时钟。** 在 `launchWebScaffold` 与 `newEnglishPage` 内固定时钟可以一次覆盖所有场景,包括将来以同样方式失败的场景。它因影响面而落选:83 个 golden 带有时钟,且会话树 golden 断言字面相对时间,因此通道级的 now 会同时把所有场景移离各自的会话与 fixture 时间,而每个场景仍需自己的锚点才能读出录制时的分桶。
|
||||
|
||||
**让场景避开本地午夜。** 在边界附近调度或拒绝运行会把依赖留在原处,并让运行而非被测代码失败。[CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 已经拒绝用预算或重试替代受控观察。
|
||||
|
||||
## Verification
|
||||
|
||||
两个负向对照各固定一个时钟、把另一个留在墙上时钟,精确复现了记录到的特征:`steering` 固定浏览器时钟而 Host 时钟实时时,两个 golden 用例以 `… and stop. 9/26 {{clock}}` 失败;`message-actions` 固定浏览器时钟但仍用实时锚点的种子时,每行时钟都以 `9/26 {{clock}}` 失败,且本应断言 `1min` 的 fork golden 读出 `now`。两个时钟都固定后两个文件均通过,且它们的 golden 未改动。
|
||||
|
||||
排队场景的启动竞态是实测而非推断:固定时钟且不等待忙碌状态时,`vitest run … -t "queues two messages"` 在 61 次中失败 10 次,全部停在同一个 `2 queued messages` 等待上;未加固定时钟的 `origin/master` 在 41 次中失败 0 次;加上该观察后 20 次全部通过。
|
||||
|
||||
CI 运行 36179748541(作业 108218939163)显示日历修复在通道自身的拓扑下成立:提示行匹配 `{{clock}}`,而运行 36157253018 渲染的是 `9/25 {{clock}}`。
|
||||
|
||||
## Consequences
|
||||
|
||||
这四个场景无论何时运行都渲染同一行 aria,其 golden 继续断言不带日期前缀的时钟。日历事实现在按场景归属:自行输入消息、或种子 fixture 头部为 `createdAt: 0` 的场景必须让两个时钟都读取 fixture 日,否则会继承午夜暴露。产品时钟渲染、`Asia/Shanghai` 浏览器时区、所有已提交的 golden,以及本说明未列出的场景的断言均不变。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-observation-waits-on-observed-state.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read:
|
||||
en: 8949f81c9d585fc0
|
||||
zh: 34e1bdbca1cf2126
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read/problem:
|
||||
en: 270b015afc8de8ac
|
||||
zh: 65aeb1e270d19a27
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read/decision:
|
||||
en: 479cb25421102304
|
||||
zh: 9e23396728001e2c
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read/alternatives-considered:
|
||||
en: 00e59c561085e081
|
||||
zh: 4f6ac4dfc3259f58
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read/consequences:
|
||||
en: 13db32f9da19d87b
|
||||
zh: 48081fc7035dea45
|
||||
/agent-note-windows-lane-observations-wait-on-the-state-they-read/deferred:
|
||||
en: 18dc81f98375fb04
|
||||
zh: e30413cd3d201b80
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Windows-lane observations wait on the state they read
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-observation-waits-on-observed-state.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Two self-hosted Windows lane cases observed state through a wall-clock window instead of the state they assert on.
|
||||
|
||||
`packages/boot/plugin-manager/tests/operations.spec.ts` armed a 100 ms timer when the case started and read the profile's `run.json` when it fired, to observe the run record while the run was still in flight. The repaired installation it observes runs only after a refused installation, a `pnpm view` lookup, the restoration of the profile files, and a second `launch`, so the timer measured that whole prefix rather than the record write. Under the loaded runner the repair began after the deadline and the case failed with `expected null to deeply equal { pid: 4343, grouped: false }` (PR #4595 `windows node 24 / coverage`, job 108402945554, 2026-09-26; the case took 345 ms, and its sibling single-run cases, which need no such prefix, passed in 113 ms and 116 ms).
|
||||
|
||||
`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` counts each send's settlement tier and requires the controlled-prompt fast path, but reported only the count, so a failure could not say which tier settled which send — the one fact that separates a degraded readiness path from a mis-sized window.
|
||||
|
||||
## Decision
|
||||
|
||||
`observingChild` stays in flight until the test has read the run record, so the read cannot race the run's end, and its bounded wait starts at the mocked launcher's hand-over instead of at test start: the recorded run is one atomic write that follows the launch, so the bound only has to cover that write. `RECORD_WAIT_MS` (2 s) is deliberately below the case budget, so a record that never appears reports as the assertion that names it rather than as a runner timeout. Both the repair-install case and the single-run case hand the child over through the same `spawned` hook.
|
||||
|
||||
The loader-composition settle-reason assertion carries `JSON.stringify(settleReasons)` as its failure message. The threshold and the assertions are unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Raise the fixed timer, or poll for the record from test start.** Rejected: both keep the window anchored before the operation reached the run it observes, so the bound has to cover the whole prefix and grows with whatever the runner does before the repair run — the same mistake at a larger size. Anchoring on the hand-over measures only the state transition being waited for.
|
||||
|
||||
**Wait for the record without a bound.** Rejected: a record the operation never writes would hang the case until the runner's own timeout and report nothing about the record.
|
||||
|
||||
**Record only the settlements that matter, or assert a subset of sends.** Rejected: it weakens what the suite pins — that no send falls back to the silence tier — to make the failure go away.
|
||||
|
||||
## Consequences
|
||||
|
||||
Both plugin-manager record cases now observe the record while the run is in flight on a loaded runner, and a genuinely missing record still fails the same assertion. The bounded wait costs 2 s only when the operation never writes the record.
|
||||
|
||||
## Deferred
|
||||
|
||||
The loader-composition failure of 2026-09-25/26 is an environment-sensitive readiness window, not a product logic error, and the product now owns a documented tolerance for it: the [prompt tail grace decision](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md). On Windows `isStdinWaiting` returns false and `foregroundPgid` returns the shell pid, so `stdin_read` can only come from the controlled-prompt tail. A native Windows probe (Windows 11 ARM64 guest, 8 vCPU, with the readiness probe enabled) shows the fast path is deterministic when delivery is normal: every prompt render is the OSC marker alone in one pty chunk followed by a five-byte `dsh> ` chunk 2-28 ms later (56 renders), nothing printable follows any prompt, the output carries no terminal queries, and 21 runs (baseline, six CPU burners, emulated x64, four concurrent pty-heavy instances, and Windows PowerShell 5.1 as the shell) settled all seven sends on the prompt, six `stdin_read` plus the `exit` command's `session_exit`. Withholding each session's prompt tail for four seconds reproduces the reported failure exactly: `expected 4 to be greater than or equal to 6` with `["inferred_idle","stdin_read","stdin_read","stdin_read","stdin_read","session_exit","inferred_idle"]`, the degraded settles carrying `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3301/3313 ms (exactly the configured bound), 22.1/21.9 s against 17.5/20.9 s in CI, and every output assertion still passing. The CI trigger itself was not reproduced locally, so this case keeps its assertion and supplies `promptTailGraceMs` in its composition: it measures the controlled-prompt path rather than the host's delivery timing, and it still fails when no prompt marker appears at all.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Windows lane 的观测等待它所读取的状态
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-observation-waits-on-observed-state.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
自托管 Windows lane 的两个用例用墙钟窗口观测状态,而不是等待它们真正断言的那个状态。
|
||||
|
||||
`packages/boot/plugin-manager/tests/operations.spec.ts` 在用例开始时启动一个 100 毫秒定时器,并在它触发时读取 profile 的 `run.json`,以此在该 run 仍在进行时观测 run 记录。它要观测的「修复安装」只有在一次被拒绝的安装、一次 `pnpm view` 查询、profile 文件恢复以及第二次 `launch` 之后才会发生,因此该定时器度量的是这整段前缀,而不是记录写入本身。在负载较高的 runner 上修复安装晚于该截止时刻才开始,用例因而以 `expected null to deeply equal { pid: 4343, grouped: false }` 失败(PR #4595 的 `windows node 24 / coverage`,job 108402945554,2026-09-26;该用例耗时 345 毫秒,而相邻的单 run 用例不需要这段前缀,分别以 113 毫秒和 116 毫秒通过)。
|
||||
|
||||
`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 统计每次 send 的结算层级并要求受控提示符快路径,但只报告数量,失败时无法说明哪一层级结算了哪一次 send——而这正是区分「就绪路径退化」与「窗口取值不当」的唯一事实。
|
||||
|
||||
## Decision
|
||||
|
||||
`observingChild` 会保持该 run 在进行中,直到测试读到 run 记录,因此读取不可能与 run 结束竞争;其有界等待从被 mock 的启动器交接处开始,而不是从用例开始处开始:被记录的 run 只是紧随启动之后的一次原子写入,因此等待上界只需覆盖这次写入。`RECORD_WAIT_MS`(2 秒)刻意低于用例预算,于是一条始终没有出现的记录会以指名它的断言报告,而不是以 runner 超时报告。修复安装用例与单 run 用例都通过同一个 `spawned` 钩子交接子进程。
|
||||
|
||||
loader-composition 的结算层级断言把 `JSON.stringify(settleReasons)` 作为失败信息。阈值与断言本身不变。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**提高固定定时器的时长,或从用例开始处轮询记录。** 不采纳:两者都把窗口锚定在操作尚未到达它所观测的 run 之前,于是上界必须覆盖整段前缀,并随 runner 在修复 run 之前的行为一起增长——同样的错误,只是更大。锚定在交接处则只度量真正要等待的那个状态转换。
|
||||
|
||||
**无上界地等待记录。** 不采纳:若操作永不写入记录,用例会挂到 runner 自身的超时,且对记录本身不给出任何信息。
|
||||
|
||||
**只记录关键的那几次结算,或断言 send 的子集。** 不采纳:这会把该套件所固定的内容——任何 send 都不得回退到静默层——削弱成让失败消失。
|
||||
|
||||
## Consequences
|
||||
|
||||
在负载较高的 runner 上,两个插件管理器记录用例现在都能在该 run 仍在进行时观测到记录,而真正缺失的记录仍会以同一条断言失败。只有在操作始终不写记录时,这段有界等待才会付出 2 秒。
|
||||
|
||||
## Deferred
|
||||
|
||||
loader-composition 于 2026-09-25/26 的失败是一个对投递时序敏感的就绪窗口,而不是 product 逻辑错误;product 现在为它拥有一个文档化的容忍度:[提示符尾部宽限决策](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)。在 Windows 上 `isStdinWaiting` 返回 false、`foregroundPgid` 返回 shell 自身的 pid,因此 `stdin_read` 只能来自受控提示符尾部。原生 Windows 探测(Windows 11 ARM64 guest,8 vCPU,启用就绪探测)表明只要投递正常,快路径就是确定的:每次提示符渲染都是 OSC 标记独占一个 pty 分块,紧随其后的下一个分块是 5 字节的 `dsh> `,间隔 2–28 毫秒(56 次渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询;21 次运行(基线、6 个 CPU burner、模拟 x64、4 个并发 pty 实例、以及以 Windows PowerShell 5.1 作为 shell)全部七次 send 都按提示符结算——六次 `stdin_read` 加 `exit` 命令的 `session_exit`。把每个 session 的提示符尾部扣留 4 秒可以精确复现上报的失败:`expected 4 to be greater than or equal to 6`,`settleReasons` 为 `["inferred_idle","stdin_read","stdin_read","stdin_read","stdin_read","session_exit","inferred_idle"]`,退化的结算带着 `promptSeen` 为 true、`promptTextSeen` 为 false、空尾部与 `idleFor` 3301/3313 毫秒(正是配置的上界),耗时 22.1/21.9 秒对照 CI 的 17.5/20.9 秒,且所有输出断言仍然通过。CI 的触发条件本身未能在本地复现,因此该用例保留其断言并在组合中提供 `promptTailGraceMs`:它度量受控提示符路径而不是宿主机的投递时序,且在完全没有提示符标记时仍然失败。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-plugin-manager-git-child-environment.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.md
|
||||
/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration:
|
||||
en: ebb8f6a97dcc126a
|
||||
zh: 4f7fbb487136f218
|
||||
/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/problem:
|
||||
en: 67f7316995be5f48
|
||||
zh: fbb8dda3946ff1f3
|
||||
/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/decision:
|
||||
en: 7723360996b8b09e
|
||||
zh: 78d8dfa61b1711ec
|
||||
/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/alternatives-considered:
|
||||
en: 3f4e5b0bc9a8a961
|
||||
zh: 5f39ffd20e076914
|
||||
/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/verification:
|
||||
en: 7c8b5b8f4002c71a
|
||||
zh: 59493bd968ba34bf
|
||||
/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/consequences:
|
||||
en: b81437323c8ae6e2
|
||||
zh: 79f8d0926218ae2e
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Plugin-manager Git children isolate the host's command-line configuration
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-plugin-manager-git-child-environment.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The self-hosted Windows serial lane failed 13 cases in run 36227311113 (job 108363684565); 12 share one cause: [github-connection.spec.ts](../../../../packages/boot/plugin-manager/tests/github-connection.spec.ts) 9 (the six install-address forms and three proxy cases) and [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) 3 real-Git install cases. The host carried Git's indexed command-line configuration — `GIT_CONFIG_COUNT` with `GIT_CONFIG_KEY_0` / `GIT_CONFIG_VALUE_0`, the group Git hands to `git -c` calls and their hook children. The shared subprocess scrub (`scrubbedParentEnv`) removes credential-shaped names, so it stripped the key and kept the counter; Git reads the counter first, and every child died with `error: missing config key GIT_CONFIG_KEY_0` / `fatal: unable to parse command-line config` (exit 128) before opening any configuration file.
|
||||
|
||||
## Decision
|
||||
|
||||
Both spec files delete Git's command-line configuration group from their own process environment before they spawn Git, through [isolateGitCommandLineConfig](../../../../packages/boot/plugin-manager/tests/git-environment.ts). These specs judge profile behavior — profile Git configuration, real installs — rather than host state, so a host that exports the group no longer changes their outcome. The helper restores the removed entries after the file with `vi.stubEnv(name, undefined)` and `vi.unstubAllEnvs()`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Remove the group whole in `scrubbedParentEnv`.** The product-side fix treats the counter and its keys as one unit, but it changes shared subprocess behavior for every consumer, which the review directive for this change excludes. It is preserved on the branch `deferred/git-command-line-config-scrub-20260927` for a future product PR with its own justification.
|
||||
|
||||
**Pin `GIT_CONFIG_COUNT: '0'` at the plugin-manager call sites.** `packages/deliverables/workspace-changes/src/git.ts` does exactly that for its children, so the same two lines in `github-connection.ts` and `operations.ts` would cover this class. That is still a product behavior change, so it stays out of scope here.
|
||||
|
||||
**Clear the group in the lane's workflow.** The group reaches the job from the host image rather than from a step this repository owns, and other Git-spawning specs would stay exposed.
|
||||
|
||||
## Verification
|
||||
|
||||
`GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.bare GIT_CONFIG_VALUE_0=true pnpm exec vitest run packages/boot/plugin-manager/tests/github-connection.spec.ts packages/boot/plugin-manager/tests/manager.spec.ts` fails 12 cases at the parent revision with the lane's assertion set and passes 95/95 with the isolation in place.
|
||||
|
||||
## Consequences
|
||||
|
||||
The two specs pass on a host that exports Git command-line configuration. The product's treatment of such hosts is unchanged, so the same environment still breaks any other consumer of the shared scrub; a future product change to `scrubbedParentEnv` must revisit this note.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Agent Note: plugin-manager 的 Git 子进程隔离宿主机的命令行配置
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-plugin-manager-git-child-environment.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
自托管 Windows 串行 lane 在运行 36227311113(作业 108363684565)中失败 13 个用例,其中 12 个同一成因:[github-connection.spec.ts](../../../../packages/boot/plugin-manager/tests/github-connection.spec.ts) 9 个(六种安装地址形式与三个代理用例)与 [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) 3 个真实 Git 安装用例。该宿主机携带 Git 的索引式命令行配置——`GIT_CONFIG_COUNT` 加上 `GIT_CONFIG_KEY_0` / `GIT_CONFIG_VALUE_0`,即 Git 交给 `git -c` 调用及其 hook 子进程的那一组变量。共享的子进程清洗(`scrubbedParentEnv`)按凭据形态的名字移除变量,于是移除了键却保留了计数;Git 先读计数,每个子进程都在打开任何配置文件之前以 `error: missing config key GIT_CONFIG_KEY_0` / `fatal: unable to parse command-line config`(退出码 128)失败。
|
||||
|
||||
## Decision
|
||||
|
||||
这两个 spec 文件在派生 Git 之前,从自身进程环境里删除 Git 的命令行配置组,由 [isolateGitCommandLineConfig](../../../../packages/boot/plugin-manager/tests/git-environment.ts) 完成。这些 spec 判定的是 profile 行为——profile 的 Git 配置、真实安装——而不是宿主机状态,因此导出该组的宿主机不再改变它们的结果。辅助函数在文件结束后用 `vi.stubEnv(name, undefined)` 与 `vi.unstubAllEnvs()` 还原被移除的条目。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 `scrubbedParentEnv` 中整体移除该组。** 产品侧修复把计数与它的键当作一个整体,但它会改变所有消费者共享的子进程行为,而本次改动的 review 指令排除了这一点。该修复保留在分支 `deferred/git-command-line-config-scrub-20260927`,留待将来带自身论证的产品 PR。
|
||||
|
||||
**在 plugin-manager 的调用点固定 `GIT_CONFIG_COUNT: '0'`。** `packages/deliverables/workspace-changes/src/git.ts` 正是这样对待自己的子进程,因此在 `github-connection.ts` 与 `operations.ts` 加同样两行即可覆盖这一类。但那仍属产品行为改动,故不在此范围内。
|
||||
|
||||
**在 lane 的工作流里清除该组。** 该组来自宿主机镜像,而不是本仓库拥有的某个步骤,且其他会派生 Git 的 spec 仍会暴露。
|
||||
|
||||
## Verification
|
||||
|
||||
`GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.bare GIT_CONFIG_VALUE_0=true pnpm exec vitest run packages/boot/plugin-manager/tests/github-connection.spec.ts packages/boot/plugin-manager/tests/manager.spec.ts` 在父修订上以 lane 的断言集合失败 12 个用例,加入隔离后 95/95 通过。
|
||||
|
||||
## Consequences
|
||||
|
||||
这两个 spec 在导出 Git 命令行配置的宿主机上通过。产品对此类宿主机的处理未变,因此同一环境仍会破坏共享清洗的其他消费者;将来若改动 `scrubbedParentEnv`,必须回头审视本记录。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-web-lane-assertions-name-their-input-state.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.md
|
||||
/agent-note-web-lane-assertions-name-their-input-state:
|
||||
en: 6b04734ce8fbeb4a
|
||||
zh: 73db3bd3ac5985dc
|
||||
/agent-note-web-lane-assertions-name-their-input-state/problem:
|
||||
en: 12391509553e0152
|
||||
zh: 86d02884ae9c1b09
|
||||
/agent-note-web-lane-assertions-name-their-input-state/decision:
|
||||
en: c6a8e79707975429
|
||||
zh: 4fe1f6abd6fd4c1e
|
||||
/agent-note-web-lane-assertions-name-their-input-state/alternatives-considered:
|
||||
en: 6cd5940f55500c3d
|
||||
zh: ded68b931ed81e10
|
||||
/agent-note-web-lane-assertions-name-their-input-state/verification:
|
||||
en: 6d11abb7a60f81ae
|
||||
zh: 42bc43777a3b02b7
|
||||
/agent-note-web-lane-assertions-name-their-input-state/consequences:
|
||||
en: 2ecf084ca4df49c4
|
||||
zh: bbd8823fd0910609
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# Agent Note: Web lane assertions name their input state
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-web-lane-assertions-name-their-input-state.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Three browser-lane scenarios captured an aria region while a state the golden records was only implied by whatever the pointer, the keyboard, or an in-flight Host read happened to be doing at that instant, so each capture could land on the other state.
|
||||
|
||||
[workspace-new-session-folding](../../../../apps/web/tests/workspace-new-session-folding.e2e.ts) captures two goldens of one sidebar. `sidebar.expected.md` records the Workspace row with its row-actions cell revealed, which [Rows.module.css](../../../../packages/client/ui-workspace/src/client/rows/Rows.module.css) does under `.projectRow:hover`; `first-batch.expected.md` records the sixth session that way under `.sessionRow:hover`, the first row the batch reveals. Both captures therefore depend on where the pointer sits, and the scenario's own "Show 11 more sessions" click reflows the list under a stationary pointer. The serial self-hosted master lane failed this scenario with both goldens' requirements unmet — `treeitem "{{workspace}}"` without the actions, and the golden's hovered row rendering its plain relative time — in every one of the nine failing runs observed between 2026-09-24 and 2026-09-27.
|
||||
|
||||
[turn-tail-actions](../../../../apps/web/tests/turn-tail-actions.e2e.ts) captures the Copy footer focused twice: `running.expected.md` records a `tooltip "Copy"` beside it, `settled.expected.md` records none. [Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx) ignores focus while the last input came from a pointer, and only a keydown clears that flag, so whether the first capture shows the bubble depends on whether the harness pressed a key after the scenario's last click.
|
||||
|
||||
[plugin-install-cancel](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) read the enabled plugin card's `data-plugin-status` once after the highlighted card appeared. The store's `enableInstalled` marks the card and starts the directory reload the Host answers, so the highlight is observable before the reload commits the enabled bundle into the list: the status flipped `disabled` → `running` 61 ms after the highlight in three local runs, and the run that failed read it inside that window.
|
||||
|
||||
## Decision
|
||||
|
||||
Each capture names the state its golden records before it captures.
|
||||
|
||||
The folding scenario hovers the Workspace row before the sidebar capture and the first row the batch reveals before the first-batch capture, so both hovered rows are established rather than inherited from the layout around the Show-more click. The goldens, the folding quota, and the row rendering are unchanged; the scenario now performs the hover a reader of the golden would perform.
|
||||
|
||||
The turn-tail scenario clears the pointer flag with a key that moves nothing, focuses the Copy button, and waits for the tooltip the running golden records. The settled capture keeps the plain focus, which is what its golden records once the Stop click has left the pointer owning the last input.
|
||||
|
||||
The install-cancel scenario polls the card's status instead of sampling it, so the assertion observes the directory reload that the highlight precedes. The highlight stays the locator that finds the card, because the later `2400 ms` clock advance still asserts the card detaches with it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Collapse the hovered and unhovered renderings in the aria normalizer.** The two renderings state different facts — one row is hovered, the others are not — and both goldens deliberately record the row-actions cell and the relative time. Collapsing them would delete the assertion rather than stabilize it, the same trade every lane golden that deliberately records a dated or stateful form already rejects.
|
||||
|
||||
**Deliver a blank event and keep the pointer where the click leaves it.** The button moves as the batch inserts, so the row under the pointer still follows layout: the folding negative control below reproduces the failure with the pointer inside the sidebar and no row hovered.
|
||||
|
||||
**Disable hover styling or the tooltip modality rule in the scenario.** Both are shipped rendering and interaction behavior the goldens are the evidence for; a scenario-local override would test a page the product does not serve.
|
||||
|
||||
**Raise the capture timeout or retry the comparison.** Neither establishes which state was captured; `captureStableAria` already waits for two equal consecutive snapshots, and it was satisfied by the wrong state — the limit the [CI completion decision](2026-09-08-ci-readiness-and-completion.md) and the [connection and compaction fixture preconditions](2026-09-12-connection-and-compaction-fixture-preconditions.md) already record.
|
||||
|
||||
## Verification
|
||||
|
||||
Two negative controls reproduce the recorded signatures with the shipped assertions. Hovering the New Session row instead of the Workspace row before the folding scenario's first capture fails `sidebar.expected.md` with `treeitem "{{workspace}}"` where the golden records `treeitem "{{workspace}} Workspace actions …"`. A bare mouse press with no key before the turn-tail scenario focuses the Copy button raises no bubble, and the running capture then fails with the missing `tooltip "Copy"` the serial lane recorded. Both controls pass after the change.
|
||||
|
||||
Measured locally, one file at a time: `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<file>.e2e.ts` → workspace-new-session-folding 15/15 before the change and 20/20 after, turn-tail-actions 8/8 before and 20/20 after, plugin-install-cancel 12/12 before and 20/20 after. The lane configuration `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` passes all three files in three consecutive local runs.
|
||||
|
||||
The install-cancel scenario carries a residual exposure this change does not remove and whose cause is not diagnosed: twice on `node 24 / snapshots and artifacts`, and once locally with the pre-change file on the same lane command, the highlighted card locator at `plugin-install-cancel.e2e.ts:217` stayed unmatched for 30 s after the enable click closed the dialog. The paused page clock rules out the highlight expiry timer, so the wait is not racing that; no local focused run or the three lane runs above reproduced it, and nothing here is known to hang the store's `load()`. The poll added by this change covers the status read after the card appears, not this locator.
|
||||
|
||||
The WebKit case the serial master lane also failed is not a scenario race and stays out of this change: `declared-reasoning.e2e.ts` launches Playwright's WebKit, the lane's step fetched that browser without its host libraries, and `browserType.launch` failed with "Host system is missing dependencies to run browsers". [ci.yml](../../../../.github/workflows/ci.yml) states the persistent VM image owns Playwright's Linux system packages and installs the dependency set only off the self-hosted pool, so repairing the image or the pool's ownership is a runner change, not a scenario one. Installing WebKit's libraries on a developer machine makes the file's 8 cases pass.
|
||||
|
||||
## Consequences
|
||||
|
||||
The three scenarios render the aria their goldens record whatever state the harness arrives in, and no golden, product surface, or timeout changed. The input state is now owned by each capture: a scenario that captures a hovered row, a focused Tooltip anchor, or a value published by a later Host read must establish that state itself. `document-preview.e2e.ts`'s spreadsheet-selection read has the same class of defect and stays with its own change.
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# Agent Note: Web lane assertions name their input state
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-web-lane-assertions-name-their-input-state.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
浏览器通道的三个场景在捕获 aria 区域时,golden 记录的状态只是被指针、键盘或一次进行中的 Host 读取所隐含,因此每次捕获都可能落到另一种状态上。
|
||||
|
||||
[workspace-new-session-folding](../../../../apps/web/tests/workspace-new-session-folding.e2e.ts) 捕获同一侧边栏的两份 golden。`sidebar.expected.md` 记录的是 Workspace 行显示出行操作单元格的状态,该行为由 [Rows.module.css](../../../../packages/client/ui-workspace/src/client/rows/Rows.module.css) 的 `.projectRow:hover` 提供;`first-batch.expected.md` 则以 `.sessionRow:hover` 记录第六个会话行,即该批次展开出的第一行。两次捕获因此都取决于指针所在位置,而场景自身的 “Show 11 more sessions” 点击会在指针静止时重排列表。串行自托管 master 通道在这个场景上失败时,两份 golden 的要求都没有满足——`treeitem "{{workspace}}"` 不带操作按钮,golden 记录为悬停的那一行渲染出普通的相对时间——2026-09-24 至 2026-09-27 观察到的九次失败运行全部如此。
|
||||
|
||||
[turn-tail-actions](../../../../apps/web/tests/turn-tail-actions.e2e.ts) 两次捕获聚焦后的 Copy 页脚:`running.expected.md` 记录其旁有一个 `tooltip "Copy"`,`settled.expected.md` 则不记录。只要最后一次输入来自指针,[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx) 就忽略聚焦,而只有 keydown 会清除该标记,因此第一次捕获是否出现气泡,取决于场景最后一次点击之后测试框架是否按过键。
|
||||
|
||||
[plugin-install-cancel](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) 在带高亮的插件卡片出现后只读一次 `data-plugin-status`。store 的 `enableInstalled` 先标记卡片,再启动由 Host 应答的目录重载,因此高亮先于重载把已启用的 bundle 写入列表即可观察:三次本地运行中状态在 `disabled` → `running` 之间用了 61 ms,失败的那次运行正是在这个窗口内读取的。
|
||||
|
||||
## Decision
|
||||
|
||||
每次捕获都先确立其 golden 所记录的状态。
|
||||
|
||||
folding 场景在侧边栏捕获前悬停 Workspace 行,在 first-batch 捕获前悬停该批次展开出的第一行,两个被悬停的行都由场景确立,而不再继承 Show-more 点击周围的布局。golden、折叠配额与行渲染均不变;场景现在执行的是 golden 读者本会执行的那次悬停。
|
||||
|
||||
turn-tail 场景先用一个不移动焦点的按键清除指针标记,再聚焦 Copy 按钮,并等待 running golden 记录的气泡。settled 捕获保留普通聚焦,这正是 Stop 点击让指针拥有最后一次输入之后、其 golden 所记录的状态。
|
||||
|
||||
install-cancel 场景改为轮询卡片状态,而不再单次采样,因此该断言观察到的是高亮所先于的目录重载。高亮仍保留在定位卡片的 locator 中,因为随后推进 `2400 ms` 时钟的那步仍以它断言卡片会脱离。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 aria normalizer 中折叠悬停与非悬停两种渲染。** 两者陈述不同事实——一行被悬停,其余没有被悬停——且两份 golden 都在有意记录行操作单元格与相对时间。折叠它们是在删除断言而非稳定断言,这与通道中每一份有意记录带日期或带状态形式的 golden 所拒绝的取舍相同。
|
||||
|
||||
**投递空白事件并让指针停留在点击后的位置。** 批次插入时按钮随之移动,指针下的行仍由布局决定:下面的 folding 负向对照在指针位于侧边栏内、没有任何行被悬停时复现了该失败。
|
||||
|
||||
**在场景内禁用悬停样式或 tooltip 的模态规则。** 两者都是 golden 所证明的已发布渲染与交互行为;场景级覆盖将测试一个产品并不提供的页面。
|
||||
|
||||
**提高捕获超时或重试比较。** 两者都不能确立实际捕获到的是哪个状态;`captureStableAria` 本已等待连续两次快照相等,而错误的状态同样能满足它——这一限度已由 [CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 与 [连接与压缩 fixture 前置条件](2026-09-12-connection-and-compaction-fixture-preconditions.zh.md) 记录。
|
||||
|
||||
## Verification
|
||||
|
||||
两个负向对照用已发布的断言复现了记录到的特征。folding 场景第一次捕获前改为悬停 New Session 行而非 Workspace 行时,`sidebar.expected.md` 失败:golden 记录 `treeitem "{{workspace}} Workspace actions …"`,实际得到 `treeitem "{{workspace}}"`。turn-tail 场景在聚焦 Copy 按钮前只做一次鼠标按下而不按键时不会出现气泡,running 捕获随后失败并缺少串行通道记录到的 `tooltip "Copy"`。两个对照在改动之后均通过。
|
||||
|
||||
本地按文件实测:`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<file>.e2e.ts` → workspace-new-session-folding 改动前 15/15、改动后 20/20,turn-tail-actions 改动前 8/8、改动后 20/20,plugin-install-cancel 改动前 12/12、改动后 20/20。通道配置 `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` 连续三次运行中三个文件全部通过。
|
||||
|
||||
install-cancel 场景仍带有本次改动未消除、且原因尚未定位的残余暴露:`node 24 / snapshots and artifacts` 上有两次、把该文件恢复到改动前版本后用同一通道命令运行的本地一次,都在启用点击关闭对话框后让 `plugin-install-cancel.e2e.ts:217` 的高亮卡片 locator 持续 30 s 未匹配。页面时钟已暂停,因此可排除高亮到期计时器,该等待并非与它竞争;上述三次通道运行与所有单文件运行都未复现,且尚无证据表明 store 的 `load()` 会挂起。本次改动新增的轮询覆盖的是卡片出现之后的状态读取,而不是这个 locator。
|
||||
|
||||
串行 master 通道同时失败的 WebKit 用例不是场景竞态,也不属于本次改动:`declared-reasoning.e2e.ts` 启动 Playwright 的 WebKit,通道的步骤只取浏览器本体而未安装宿主库,于是 `browserType.launch` 以 “Host system is missing dependencies to run browsers” 失败。[ci.yml](../../../../.github/workflows/ci.yml) 明确说明持久 VM 镜像拥有 Playwright 的 Linux 系统包,并且只在非自托管池随浏览器安装依赖集,因此修复镜像或该池的归属是 runner 侧的改动,而非场景改动。在开发者机器上安装 WebKit 依赖后该文件的 8 个用例全部通过。
|
||||
|
||||
## Consequences
|
||||
|
||||
三个场景无论测试框架以何种状态进入,都渲染其 golden 记录的 aria,且没有任何 golden、产品界面或超时被改动。输入状态现在由每次捕获自己拥有:凡是捕获悬停行、聚焦的 Tooltip 锚点,或由后续 Host 读取发布的值的场景,都必须自行确立该状态。`document-preview.e2e.ts` 的电子表格选区读取属于同一类缺陷,仍留给它自己的改动。
|
||||
@@ -35,7 +35,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
pages: read
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Sandbox CI: the keyless real-kernel confinement proofs. This master-only
|
||||
# reference stays outside the pull-request verdict; rationale lives in
|
||||
# Sandbox CI: the keyless real-kernel confinement proofs, plus the darwin
|
||||
# unit-parity job (unit-darwin) that needs the matrix's macOS image. This
|
||||
# master-only reference stays outside the pull-request verdict; rationale lives in
|
||||
# .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md.
|
||||
# A separate workflow from ci.yml because the axis is different — these jobs
|
||||
# A separate workflow from ci.yml because the axis is different — the e2e jobs
|
||||
# fan out over OS×runner (kernel capabilities), not node versions. The Landlock
|
||||
# launcher is built from native/system on each Landlock leg and installed
|
||||
# from the same tarballs the main-repository release workflow publishes.
|
||||
@@ -96,14 +97,6 @@ jobs:
|
||||
pnpm --dir native/system run build:ts
|
||||
pnpm --dir native/system run build:native
|
||||
|
||||
# The unit suite runs on ubuntu in `checks`; this is the one darwin leg
|
||||
# in the workflow, so run it here too — the platform-dependent unit
|
||||
# expectations (Seatbelt path canonicalization: /tmp IS /private/tmp)
|
||||
# take their darwin branch only on this runner.
|
||||
- name: Unit tests (darwin parity)
|
||||
if: matrix.runner == 'seatbelt'
|
||||
run: pnpm run test
|
||||
|
||||
- name: Sandbox e2e (real kernel confinement, world-verified)
|
||||
# NO_COLOR: vitest force-enables ANSI color under GITHUB_ACTIONS even
|
||||
# without a TTY, which would thread escape codes through the summary
|
||||
@@ -139,3 +132,30 @@ jobs:
|
||||
echo "$out"
|
||||
[ "$status" -eq 0 ]
|
||||
echo "$out" | grep -qE 'Test Files[[:space:]]+1 passed \(1\)'
|
||||
|
||||
# The unit suite runs on ubuntu in ci.yml's Linux jobs; macos-latest is this
|
||||
# workflow's only darwin runner, so the platform-dependent unit expectations
|
||||
# (Seatbelt path canonicalization: /tmp IS /private/tmp — sandbox semantics,
|
||||
# hence this workflow rather than ci-master.yml) take their darwin branch only
|
||||
# here. A job of its own so the seatbelt e2e leg keeps the matrix budget. The
|
||||
# budget below is a completion ceiling, not a latency target: the full suite
|
||||
# takes 16 minutes on a fast macos-latest runner and was under half done at
|
||||
# 20 on the slowest observed.
|
||||
unit-darwin:
|
||||
name: unit tests (darwin parity, macos-latest)
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Unit tests (darwin parity)
|
||||
run: pnpm run test
|
||||
@@ -56,7 +56,6 @@ External packages installed for runtime use or distributed inside the prebuilt b
|
||||
| [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
|
||||
| [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
|
||||
| [`@opentelemetry/core`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
|
||||
| [`@opentelemetry/exporter-logs-otlp-http`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
|
||||
| [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
|
||||
| [`@opentelemetry/otlp-transformer`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
|
||||
| [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
|
||||
|
||||
@@ -74,6 +74,8 @@ flowchart LR
|
||||
cfg --> plugin_dsh_base_storage_domain
|
||||
plugin_dsh_base_session_projection_cache["session-projection-cache<br/>@deepseek-ai/dsh-session-projection-cache"]
|
||||
cfg --> plugin_dsh_base_session_projection_cache
|
||||
plugin_dsh_base_otel["otel<br/>@deepseek-ai/dsh-otel"]
|
||||
cfg --> plugin_dsh_base_otel
|
||||
plugin_dsh_base_session_telemetry_otel["session-telemetry-otel<br/>@deepseek-ai/dsh-session-telemetry-otel"]
|
||||
cfg --> plugin_dsh_base_session_telemetry_otel
|
||||
plugin_dsh_base_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"]
|
||||
@@ -231,6 +233,7 @@ flowchart LR
|
||||
| `storage-json` | `@deepseek-ai/dsh-storage-json` |
|
||||
| `storage-domain` | `@deepseek-ai/dsh-storage-domain` |
|
||||
| `session-projection-cache` | `@deepseek-ai/dsh-session-projection-cache` |
|
||||
| `otel` | `@deepseek-ai/dsh-otel` |
|
||||
| `session-telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
|
||||
| `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
|
||||
| `sandbox` | `@deepseek-ai/dsh-sandbox-local` |
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*",
|
||||
"@deepseek-ai/dsh-cordis-client-runner": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-schedule-bundle": "workspace:*",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-goal": "workspace:*",
|
||||
"@deepseek-ai/dsh-goal-round-driver": "workspace:*",
|
||||
|
||||
@@ -28,7 +28,11 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
|
||||
}
|
||||
writeFileSync(join(root, 'package.json'), '{"type":"module"}')
|
||||
writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}')
|
||||
writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), 'export const loadProfileDirectory = () => ({ skippedBundles: [] }); export const reportSkippedBundles = () => {}; export const loadLayeredEnv = () => ({})')
|
||||
writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), `
|
||||
export const loadProfileDirectory = () => ({ skippedBundles: [] });
|
||||
export const reportSkippedBundles = () => {};
|
||||
export const loadLayeredEnv = () => ({});
|
||||
`)
|
||||
writeFileSync(join(modules, 'dsh', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}')
|
||||
writeFileSync(join(modules, 'dsh', 'profile-boot.js'), `
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
- id: schedule
|
||||
disabled: false
|
||||
@@ -8,7 +8,8 @@ import type { DailyScheduleRecord, ScheduleCatalogEntry } from '@deepseek-ai/dsh
|
||||
import { expect, it } from 'vitest'
|
||||
import { withDefaultWeb, webGet, webRequest } from './default-web-process.ts'
|
||||
|
||||
const patches = [fileURLToPath(new URL('./fixtures/schedule.patch.yml', import.meta.url))]
|
||||
/** The optional Schedule bundle is the switch this built-Web scenario turns on. */
|
||||
const patches = [fileURLToPath(new URL('../../../../../../packages/experimental/schedule-bundle/cordis.patch.yml', import.meta.url))]
|
||||
|
||||
const id = 'retained-remote-task' as DailyScheduleRecord['id']
|
||||
const sessionId = 'unloaded-owner' as ScheduleCatalogEntry['sessionId']
|
||||
|
||||
@@ -29,15 +29,13 @@ it('boots default Web without experimental modules, scheduling, time context, or
|
||||
expect(roster.plugins.length).toBeGreaterThan(roster.entries.length)
|
||||
expect(roster.modules.some(url => modulePackage(url) === '@deepseek-ai/dsh')).toBe(true)
|
||||
expect(roster.client.entries.length).toBeGreaterThan(0)
|
||||
for (const name of [
|
||||
'@deepseek-ai/dsh-client-ui-sidebar-browser',
|
||||
'@deepseek-ai/dsh-time-context',
|
||||
'@deepseek-ai/dsh-schedule',
|
||||
'@deepseek-ai/dsh-client-ui-schedule',
|
||||
]) {
|
||||
const entry = roster.entries.find(entry => entry.name === name)
|
||||
expect(entry, name).toBeDefined()
|
||||
expect(entry!.state, name).toBeUndefined()
|
||||
const browser = roster.entries.find(entry => entry.name === '@deepseek-ai/dsh-client-ui-sidebar-browser')
|
||||
expect(browser).toBeDefined()
|
||||
expect(browser!.state).toBeUndefined()
|
||||
expect(delivered.entries.some(entry => entry.id === '@deepseek-ai/dsh-client-ui-sidebar-browser')).toBe(false)
|
||||
// The optional Schedule bundle inserts these rows; the shipped composition carries none of them.
|
||||
for (const name of ['@deepseek-ai/dsh-time-context', '@deepseek-ai/dsh-schedule', '@deepseek-ai/dsh-client-ui-schedule']) {
|
||||
expect(roster.entries.some(entry => entry.name === name), name).toBe(false)
|
||||
expect(delivered.entries.some(entry => entry.id === name), name).toBe(false)
|
||||
}
|
||||
expect(experimentalRuntimeReferences(roster)).toEqual([])
|
||||
|
||||
@@ -9,6 +9,7 @@ const state: AccountView = {
|
||||
}
|
||||
|
||||
class Account extends DeepSeekAccount {
|
||||
getDeviceIdentity = async () => ({ osVersion: 'test-os' })
|
||||
fail = false
|
||||
ended = Promise.withResolvers<undefined>()
|
||||
reading = Promise.withResolvers<undefined>()
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
/deepseek-harness-desktop:
|
||||
en: 45561fc0dd3a1b20
|
||||
zh: f4b03355d4bd0693
|
||||
en: b8e4f357cf330721
|
||||
zh: cdaaf35a061f41b1
|
||||
/deepseek-harness-desktop/closing-the-window-and-quitting:
|
||||
en: fb852f3bd70d8602
|
||||
zh: 3d2b0a8d580d7f0a
|
||||
en: c323e02e3f0305fe
|
||||
zh: 3c730db632136443
|
||||
/deepseek-harness-desktop/key-technical-decisions:
|
||||
en: 069fdfd72d2ea26a
|
||||
zh: 76ae549d43c99bb4
|
||||
@@ -15,20 +15,20 @@
|
||||
en: 31cbb7452be8e1ad
|
||||
zh: 502ec556e2ec301e
|
||||
/deepseek-harness-desktop/installation-ownership:
|
||||
en: 8307baf6c5b691d0
|
||||
zh: 86e46b9552017ff6
|
||||
en: d186d3cfc9597688
|
||||
zh: 5f7094355c5f562c
|
||||
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
|
||||
en: 0d11af8639977a6d
|
||||
zh: 86b086b12d2210d3
|
||||
/deepseek-harness-desktop/develop:
|
||||
en: 9b9b362a9865e763
|
||||
zh: c748901168e2db8c
|
||||
en: 40d2ad4014bf445b
|
||||
zh: 6a08de06df9ed042
|
||||
/deepseek-harness-desktop/develop/startup-onboarding:
|
||||
en: 5e2d62207a603d81
|
||||
zh: 5fb863d4906f4a67
|
||||
en: a51346b8d880ddba
|
||||
zh: 7913214c1c66063c
|
||||
/deepseek-harness-desktop/develop/welcome-window-appearance:
|
||||
en: 7220801ccc3b2e6b
|
||||
zh: 042dc97976aef979
|
||||
en: ae319a496639b38d
|
||||
zh: ef044103240ac9da
|
||||
/deepseek-harness-desktop/package:
|
||||
en: 4cd8a8da6977928d
|
||||
zh: 9f624855a2edac37
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited.
|
||||
|
||||
The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
|
||||
|
||||
The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG.
|
||||
@@ -28,6 +30,8 @@ Windows keeps a tray icon for the whole run. Its tooltip is the product name, a
|
||||
|
||||
Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quits caused by closing the mandatory-update or welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again.
|
||||
|
||||
The mandatory update confirmation includes the installation wait notice on Windows; macOS uses the shorter restart explanation.
|
||||
|
||||
The confirmation is skipped when the installer restart already confirmed task interruption, when the fatal-recovery dialog exits or restarts, when the development Restart App and Host command runs, and during operating-system shutdown, restart, or log-off: Windows sets this on the definitive session-end message; macOS sets it on the power-off notification, and because another application can still cancel that shutdown, the next focus or show of the main window clears it. Installer-owned quit cancels any pending ordinary quit decision; late inspection results and dialog answers cannot open another confirmation or repeat cleanup. A user-initiated update download that finishes while the window is hidden defers its Install and Restart confirmation until the window is shown again; the mandatory flow keeps its taskbar and Dock attention. The Windows installer and uninstaller tell a user whose application is still running to quit it from the system tray. Desktop does not enable scheduled tasks by default, so the scheduled-task explanations appear only once that feature is on; reminders fire only in loaded sessions, and unloaded sessions are neither counted nor resumed until they are opened.
|
||||
|
||||
The tray renderer enlarges the whale by 20% around the tile center while preserving the background and aspect ratio; application and installer icons retain their original proportions.
|
||||
@@ -84,6 +88,8 @@ The `dsh-app://shell/` origin serves packaged update documents, scripts, and sty
|
||||
|
||||
The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles.
|
||||
|
||||
Update copy preserves the complete version, including prerelease suffixes, without adding v or V. When no update is available, the dialog title reports the result and the body shows the current version.
|
||||
|
||||
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
|
||||
|
||||
Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor, independently of custom shortcut bindings. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
|
||||
@@ -130,7 +136,7 @@ pnpm run start:desktop
|
||||
|
||||
The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
|
||||
|
||||
The [native/renderer keyboard tests](tests/keyboard.spec.ts) compile in [their own Client test project](../../tsconfig.desktop-keyboard-tests.json), reached by the repository Client typecheck. Its Desktop imports are limited to Cordis-free input, persistence, IPC, browser-guest, and overlay modules.
|
||||
The [native/renderer keyboard tests](tests/keyboard.spec.ts) compile as part of the repository Client typecheck. Their Desktop imports are limited to Cordis-free input, persistence, IPC, browser-guest, and overlay modules.
|
||||
|
||||
### Startup onboarding
|
||||
|
||||
@@ -138,7 +144,7 @@ The API-key input starts empty and uses `autocomplete="new-password"` to ask Chr
|
||||
|
||||
Repeated launches and `dsh://open` keep the workspace hidden until the startup credential check or a welcome action permits entry. Entering from Welcome places keyboard focus on the document without selecting a sidebar control; Tab navigation remains available.
|
||||
|
||||
Desktop checks configured model API-key references after the Host starts and before opening the workspace. With no configured key, the welcome window offers the [API-key page](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2138-44626). Save and continue writes through the existing credential service using the official DeepSeek provider's configured reference, then opens the workspace. Set up later opens the workspace without saving the draft or a completion setting; the next process launch checks credentials again. Back to sign in returns to the entry and clears the unsaved key and validation message. Buttons keep their labels and block competing actions while saving or opening the workspace. The Desktop preload marker suppresses the Web credential dialog, while retaining the Models settings page and the welcome notice.
|
||||
Desktop checks configured model API-key references after the Host starts and before opening the workspace. With no configured key, the welcome window offers the API-key page. Save and continue writes through the existing credential service using the official DeepSeek provider's configured reference, then opens the workspace. Set up later opens the workspace without saving the draft or a completion setting; the next process launch checks credentials again. Back to sign in returns to the entry and clears the unsaved key and validation message. Buttons keep their labels and block competing actions while saving or opening the workspace. The Desktop preload marker suppresses the Web credential dialog, while retaining the Models settings page and the welcome notice.
|
||||
|
||||
The welcome window reads the shared `locale.preference` before it appears. An explicit English or Chinese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector.
|
||||
|
||||
@@ -146,7 +152,7 @@ The browser-login waiting page offers a copy-link action for the current pending
|
||||
|
||||
### Welcome window appearance
|
||||
|
||||
The welcome window follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 [entry layout](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334) and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and locally bundled Montserrat Light for non-button text. The window uses macOS menu vibrancy or Windows acrylic with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. The local React welcome entry bundles React and the shared `StateDot` loading indicator with its CSS; it uses the isolated preload without loading the main Web application. The entry, sign-in status and API-key pages share a fixed bottom action row; the back-to-sign-in link sits below it. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions.
|
||||
The welcome window follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 entry layout and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and locally bundled Montserrat Light for non-button text. The window uses macOS menu vibrancy or Windows acrylic with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. The local React welcome entry bundles React and the shared `StateDot` loading indicator with its CSS; it uses the isolated preload without loading the main Web application. The entry, sign-in status and API-key pages share a fixed bottom action row; the back-to-sign-in link sits below it. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions.
|
||||
|
||||
## Package
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。
|
||||
|
||||
桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。
|
||||
|
||||
应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。
|
||||
@@ -28,6 +30,8 @@ Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单
|
||||
|
||||
所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭强制更新窗口或欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。
|
||||
|
||||
强制更新的安装确认在 Windows 上额外显示等待提示,macOS 只显示简短的重启说明。
|
||||
|
||||
以下情况跳过确认:安装更新的重启已确认过任务中断、致命错误恢复对话框中的退出或重启、开发版"重启应用与 Host"命令,以及操作系统关机、重启或注销:Windows 在确定性的会话结束消息上设置该状态;macOS 在关机通知上设置,而其他应用仍可能取消这次关机,因此主窗口下一次获得焦点或显示时会清除它。安装器接管退出时会取消尚未结束的普通退出决策;晚到的查询结果和弹框答复不会再次打开确认框或重复清理。窗口隐藏期间完成的用户主动发起的更新下载,把"安装并重启"确认推迟到窗口再次显示时;强制更新流程沿用其任务栏和 Dock 提醒。Windows 安装程序和卸载程序在应用仍在运行时提示用户先在系统托盘中退出。Desktop 默认未开启定时任务,定时任务的说明只在该功能开启后出现;提醒只在已加载的会话中触发,未加载的会话既不计入,也要等到打开后才会继续。
|
||||
|
||||
托盘渲染器以底板中心为基准将鲸鱼放大 20%,保留背景和宽高比;应用和安装器图标保持原有比例。
|
||||
@@ -86,6 +90,8 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
|
||||
|
||||
产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。
|
||||
|
||||
升级文案完整展示版本号(包括预发布后缀),不额外添加 v 或 V。无可用更新时,弹窗标题显示检查结果,正文显示当前版本。
|
||||
|
||||
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
|
||||
|
||||
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选,不受自定义快捷键绑定影响。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
|
||||
@@ -132,7 +138,7 @@ pnpm run start:desktop
|
||||
|
||||
Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
|
||||
|
||||
[原生输入与渲染进程键盘测试](tests/keyboard.spec.ts)在[独立的 Client 测试项目](../../tsconfig.desktop-keyboard-tests.json)中编译,由仓库 Client 类型检查纳入。它只导入不依赖 Cordis 的 Desktop 输入、持久化、IPC、浏览器 guest 和蒙层模块。
|
||||
[原生输入与渲染进程键盘测试](tests/keyboard.spec.ts)直接纳入仓库 Client 类型检查。它只导入不依赖 Cordis 的 Desktop 输入、持久化、IPC、浏览器 guest 和蒙层模块。
|
||||
|
||||
### 启动引导
|
||||
|
||||
@@ -140,7 +146,7 @@ API Key 输入框初始为空,并通过 `autocomplete="new-password"` 请求 C
|
||||
|
||||
重复启动和 `dsh://open` 会保持工作区隐藏,直到启动凭据检查或欢迎页操作允许进入。从 Welcome 进入时,键盘焦点落在文档上,不选中侧边栏控件;Tab 导航仍可使用。
|
||||
|
||||
Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 [API Key 页面](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2138-44626)。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。
|
||||
Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 API Key 页面。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。
|
||||
|
||||
欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文或中文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。
|
||||
|
||||
@@ -148,7 +154,7 @@ Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否
|
||||
|
||||
### 欢迎窗口外观
|
||||
|
||||
欢迎窗口使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的[入口布局](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334)和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及非按钮文字使用的本地 Montserrat Light 字体。窗口使用 macOS menu vibrancy 或 Windows acrylic,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。本地 React 欢迎入口将 React、公共 `StateDot` 加载指示器及其 CSS 一起打包;它通过隔离 preload 工作,不加载主 Web 应用。入口、登录状态和 API Key 页面共用固定的底部操作行;“返回登录”链接位于操作行下方。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。
|
||||
欢迎窗口使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的入口布局和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及非按钮文字使用的本地 Montserrat Light 字体。窗口使用 macOS menu vibrancy 或 Windows acrylic,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。本地 React 欢迎入口将 React、公共 `StateDot` 加载指示器及其 CSS 一起打包;它通过隔离 preload 工作,不加载主 Web 应用。入口、登录状态和 API Key 页面共用固定的底部操作行;“返回登录”链接位于操作行下方。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。
|
||||
|
||||
## 打包
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"vite": "^6.0.0",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:*"
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,8 @@
|
||||
#copy { min-height: 32px; padding: 3px 0; background: transparent; color: #4d6bfe; font-size: 12px; }
|
||||
#copy-message { color: #d93025 !important; }
|
||||
#manual-copy { display: block; margin-top: 8px; }
|
||||
#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; accent-color: #4d6bfe; }
|
||||
#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: #e6e7eb; accent-color: #0f1115; }
|
||||
|
||||
#progress::-webkit-progress-bar { background: #e6e7eb; }
|
||||
#progress::-webkit-progress-value { background: #0f1115; }
|
||||
#progress::-moz-progress-bar { background: #0f1115; }
|
||||
@@ -67,9 +67,13 @@ export interface DesktopAccountBackend {
|
||||
* @param listener - state recipient.
|
||||
* @param failed - stream failure recipient.
|
||||
* @param expired - live credential-expiry recipient.
|
||||
* @param onAnalyticsEnabledChanged - optional Desktop collection-policy recipient; disconnected streams publish false.
|
||||
* @returns stream disposer.
|
||||
*/
|
||||
watch(listener: (state: AccountView) => void, failed: () => void, expired: () => void): () => void
|
||||
watch(
|
||||
listener: (state: AccountView) => void, failed: () => void, expired: () => void,
|
||||
onAnalyticsEnabledChanged?: (enabled: boolean) => void,
|
||||
): () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,19 +90,21 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
|
||||
state: () => call('getState'),
|
||||
start: client => call('startSignIn', { client, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }),
|
||||
cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: client => call('signOut', { client }),
|
||||
watch(listener, failed, expired) {
|
||||
watch(listener, failed, expired, onAnalyticsEnabledChanged) {
|
||||
let closed = false
|
||||
let socket: WebSocket | undefined
|
||||
let retry: ReturnType<typeof setTimeout> | undefined
|
||||
const connect = (): void => {
|
||||
const streamId = randomUUID()
|
||||
const expiryStreamId = randomUUID()
|
||||
const analyticsPolicyStreamId = randomUUID()
|
||||
void cookies().then((cookie) => {
|
||||
if (closed) return
|
||||
const url = new URL(REMOTE_STREAM_MUX_PATH, origin)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 })
|
||||
socket.on('open', () => {
|
||||
if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } }))
|
||||
socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } }))
|
||||
socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } }))
|
||||
})
|
||||
@@ -106,6 +112,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
|
||||
try {
|
||||
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data)
|
||||
const frame = parseRemoteStreamServerMessage(bytes.toString('utf8'))
|
||||
if (frame.streamId === analyticsPolicyStreamId) {
|
||||
if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value)
|
||||
else onAnalyticsEnabledChanged?.(false)
|
||||
return
|
||||
}
|
||||
if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return }
|
||||
if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream')
|
||||
if (frame.type === 'item') listener(accountView(frame.value))
|
||||
@@ -113,11 +124,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
|
||||
} catch { socket?.close() }
|
||||
})
|
||||
socket.on('error', () => { socket?.close() })
|
||||
socket.on('close', () => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
|
||||
}).catch(() => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
|
||||
socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
|
||||
}).catch(() => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
|
||||
}
|
||||
connect()
|
||||
return () => { closed = true; clearTimeout(retry); socket?.close() }
|
||||
return () => { closed = true; onAnalyticsEnabledChanged?.(false); clearTimeout(retry); socket?.close() }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export function Welcome({ api }: { api: WelcomeApi }) {
|
||||
const [expiryNotice, setExpiryNotice] = useState(false)
|
||||
const [page, setPage] = useState<Page>('entry')
|
||||
const pageRef = useRef<Page>('entry')
|
||||
const visiblePage = useRef<Page>('entry')
|
||||
const [attempt, setAttempt] = useState<AccountView['attempt']>(null)
|
||||
const attemptRef = useRef<AccountView['attempt']>(null)
|
||||
const [starting, setStarting] = useState(false)
|
||||
@@ -69,12 +70,14 @@ export function Welcome({ api }: { api: WelcomeApi }) {
|
||||
}, [api, m.welcomeTitle])
|
||||
|
||||
useEffect(() => {
|
||||
if (page === 'entry' && visiblePage.current !== 'entry') void api.analytics?.('auth_page_view', {})
|
||||
visiblePage.current = page
|
||||
if (page === 'key') input.current?.focus()
|
||||
else if (page === 'entry' && focusEntry.current) {
|
||||
focusEntry.current = false
|
||||
keyButton.current?.focus()
|
||||
}
|
||||
}, [page])
|
||||
}, [page, api])
|
||||
|
||||
useEffect(() => {
|
||||
if (copyState !== 'copied' && copyState !== 'failed') return
|
||||
@@ -85,6 +88,7 @@ export function Welcome({ api }: { api: WelcomeApi }) {
|
||||
async function saveKey(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
if (busyRef.current) return
|
||||
void api.analytics?.('api_key_save_click', {})
|
||||
const value = draft.trim()
|
||||
if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value)
|
||||
|| ((value.startsWith('"') || value.startsWith("'") || value.charCodeAt(0) === 96) && value.at(-1) === value[0])) {
|
||||
@@ -198,15 +202,15 @@ export function Welcome({ api }: { api: WelcomeApi }) {
|
||||
<button id="auth-loading" className="primary" type="button" hidden={failed} disabled aria-label={m.welcomeAuthExchanging}>
|
||||
<StateDot state="ongoing" size={16} className="welcome-loading" />
|
||||
</button>
|
||||
<button id="auth-retry" className="primary" type="button" hidden={!failed} onClick={() => { void start() }}>{m.welcomeAuthRetry}</button>
|
||||
<button id="auth-api-key" className="secondary" type="button" hidden={!failed} onClick={() => { navigate('key') }}>{m.welcomeApiKey}</button>
|
||||
<button id="auth-retry" className="primary" type="button" hidden={!failed} onClick={() => { void api.analytics?.('auth_page_click', { button_name: 'sign_in' }); void start() }}>{m.welcomeAuthRetry}</button>
|
||||
<button id="auth-api-key" className="secondary" type="button" hidden={!failed} onClick={() => { void api.analytics?.('auth_page_click', { button_name: 'api-key' }); navigate('key') }}>{m.welcomeApiKey}</button>
|
||||
<button id="auth-cancel" className="secondary" type="button" hidden={failed}
|
||||
disabled={cancelling || phase === 'committing' || phase === 'succeeded' || (phase === 'initializing' && !attempt?.id)}
|
||||
onClick={() => { void cancel() }}>{m.welcomeAuthCancel}</button>
|
||||
</div>
|
||||
<div id="entry-actions" className="actions" hidden={page !== 'entry'}>
|
||||
<button id="sign-in" className="primary" type="button" onClick={() => { void start() }}>{m.welcomeSignIn}</button>
|
||||
<button ref={keyButton} id="api-key" className="secondary" type="button" onClick={() => { navigate('key') }}>{m.welcomeApiKey}</button>
|
||||
<button id="sign-in" className="primary" type="button" onClick={() => { void api.analytics?.('auth_page_click', { button_name: 'sign_in' }); void start() }}>{m.welcomeSignIn}</button>
|
||||
<button ref={keyButton} id="api-key" className="secondary" type="button" onClick={() => { void api.analytics?.('auth_page_click', { button_name: 'api-key' }); navigate('key') }}>{m.welcomeApiKey}</button>
|
||||
</div>
|
||||
<div id="key-actions" className="actions" hidden={page !== 'key'}>
|
||||
<button id="save-key" className="primary" type="submit" form="key-form" disabled={busy || draft.trim() === ''}>{m.welcomeKeySave}</button>
|
||||
|
||||
+65
-65
@@ -77,17 +77,17 @@ export const en = {
|
||||
updateCheckFailed: 'Could not check for updates. Please try again later.',
|
||||
updateDownloadFailed: 'Could not download the update. Please try again.',
|
||||
updateInstallFailed: 'Could not install the update. Please try again later.',
|
||||
updateCheckNetworkFailed: 'Could not check for updates. Please try again later. The connection was interrupted. Check your network and try again.',
|
||||
updateDownloadNetworkFailed: 'Could not download the update. Please try again. The connection was interrupted. Check your network and try again.',
|
||||
updateInstallNetworkFailed: 'Could not install the update. Please try again later. The connection was interrupted. Check your network and try again.',
|
||||
updateCheckNetworkFailed: 'Could not check for updates. Check your connection and try again.',
|
||||
updateDownloadNetworkFailed: 'Could not download the update. Check your connection and try again.',
|
||||
updateInstallNetworkFailed: 'Could not install the update. Check your connection and try again.',
|
||||
unknownError: 'Unknown error',
|
||||
updateCheckTitle: 'Check for Updates',
|
||||
updateCurrent: 'No updates available. Current version: V{version}',
|
||||
updateCurrentDetail: 'Current version: {version}',
|
||||
updateCurrent: 'You’re up to date!',
|
||||
updateChecking: 'Checking for updates…',
|
||||
updateDownload: 'Download update',
|
||||
updateDownloadedTitle: 'DeepSeek Harness v{version} downloaded',
|
||||
updateDownloadedDetail: 'The update package has downloaded. Select “Install and Restart” to restart the app and begin installation.',
|
||||
updateDownloadedTitleWindows: 'New version v{version} is ready',
|
||||
updateDownloadedTitle: 'Version {version} is ready to install',
|
||||
updateDownloadedDetail: 'The app will close during the update and reopen automatically when it is complete.',
|
||||
updateDownloadedDetailWindows: 'The app will close temporarily during the update and reopen automatically when it is complete.\n\nThe update may take some time. Please wait and do not launch the app again during installation.',
|
||||
updateClose: 'Close',
|
||||
updateAcknowledge: 'OK',
|
||||
@@ -97,47 +97,47 @@ export const en = {
|
||||
updateInstalling: 'Preparing to restart…',
|
||||
updateRetry: 'Retry update',
|
||||
updateActiveTasks: 'Tasks are still in progress',
|
||||
updateActiveTasksDetail: 'Restarting to update may interrupt these tasks. Continue updating?',
|
||||
updateActiveTasksDetail: 'Updating will stop the tasks in progress and restart the app. Continue?',
|
||||
updateStopTasks: 'Stop tasks and update',
|
||||
updateTasksChanged: 'New tasks started. Review the update confirmation again.',
|
||||
updateTasksChanged: 'New tasks have started. Confirm again to stop the tasks and update.',
|
||||
updateTasksUnavailable: 'Task status is unavailable. Try updating again when the workspace is ready.',
|
||||
updateStopFailed: 'Tasks could not be stopped safely. The update was not installed. Please try again later.',
|
||||
updateStopFailed: 'Could not safely stop the tasks. The update has not been installed. Please try again later.',
|
||||
updateTechnicalDetails: 'View technical details',
|
||||
updateTitle: 'DeepSeek Harness Update',
|
||||
updateAvailable: 'An update is available',
|
||||
updateDetail: 'DeepSeek Harness {version}\n\nDeepSeek Harness will restart to complete the update.',
|
||||
updateAvailable: 'New version available: {version}',
|
||||
updateDetail: 'Once the download is complete, you can install the update and restart the app.',
|
||||
installAndRestart: 'Install and Restart',
|
||||
later: 'Later',
|
||||
updateFailedTitle: 'Update Failed',
|
||||
mandatoryTitle: 'Update required',
|
||||
mandatoryDetail: 'This version is no longer supported. Update to continue. Existing tasks can keep running until you approve a restart.',
|
||||
mandatoryUnavailable: 'The update requirement could not be checked. Retry when the connection is available.',
|
||||
mandatoryTitle: 'Update to continue',
|
||||
mandatoryDetail: 'This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.',
|
||||
mandatoryUnavailable: 'Could not check update requirements. Please try again later.',
|
||||
policyLoginTitle: 'Sign in to the test environment',
|
||||
policyLoginRequired: 'This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update.',
|
||||
policyLoginRequired: 'Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update.',
|
||||
policyLogin: 'Sign in with Feishu',
|
||||
policyLoginFailed: 'Test environment sign-in did not complete. Please check your connection and try again.',
|
||||
policyLoginFailed: 'Feishu sign-in did not complete. Please try again.',
|
||||
policyLoginLoading: 'Loading sign-in page…',
|
||||
mandatoryNoRelease: 'No applicable update is available. Check again or contact support.',
|
||||
mandatoryNoRelease: 'No compatible update was found. Check again or contact support.',
|
||||
mandatoryRefresh: 'Check again',
|
||||
mandatoryPage: 'Open download page',
|
||||
mandatoryCopy: 'Copy download address',
|
||||
mandatoryPageFailed: 'The download page could not be opened. Copy the address below and open it in your browser.',
|
||||
mandatoryActionFailed: 'The update action failed. Retry; the update requirement remains active.',
|
||||
mandatoryPage: 'Download from the official website',
|
||||
mandatoryCopy: 'Copy download link',
|
||||
mandatoryPageFailed: 'Could not open the official download page. Copy the link and open it in your browser.',
|
||||
mandatoryActionFailed: 'The action failed. Please try again. You can use the app again after the update is complete.',
|
||||
mandatoryReady: 'Update ready',
|
||||
mandatoryVersion: 'V{version}',
|
||||
mandatoryReadyDetail: 'Installing the update will restart the application.',
|
||||
mandatoryDeferred: 'Existing tasks can keep running. Update to continue using the application.',
|
||||
mandatoryContinue: 'Continue installing update',
|
||||
mandatoryVersion: 'New version: {version}',
|
||||
mandatoryReadyDetail: 'The app will close during the update and reopen automatically when it is complete.',
|
||||
mandatoryDeferred: 'Tasks in progress will keep running. Complete the update before using the app again.',
|
||||
mandatoryContinue: 'Continue update',
|
||||
mandatoryInspecting: 'Checking tasks…',
|
||||
mandatoryStopping: 'Safely stopping tasks in the application.',
|
||||
mandatoryRestarting: 'The application will restart shortly. Please wait.',
|
||||
mandatoryStopping: 'Stopping tasks…',
|
||||
mandatoryRestarting: 'The app will restart shortly. Please wait.',
|
||||
mandatoryDownloadFailed: 'The update files could not be downloaded or prepared. Please retry.',
|
||||
mandatoryInstallFailed: 'The update was not installed. Check tasks again and retry.',
|
||||
mandatoryOpenHelp: 'If the page did not open, you can',
|
||||
mandatoryReopen: 'Open download page again',
|
||||
mandatoryInstallFailed: 'The update has not been installed. Check the tasks again and retry.',
|
||||
mandatoryOpenHelp: 'Page didn’t open?',
|
||||
mandatoryReopen: 'Open the official download page again',
|
||||
mandatoryCopied: 'Link copied',
|
||||
mandatoryCopyFailed: 'Copy failed. Select and copy the address below manually.',
|
||||
mandatoryAddress: 'Download address',
|
||||
mandatoryCopyFailed: 'Could not copy the link. Select and copy it below.',
|
||||
mandatoryAddress: 'Download link',
|
||||
mandatoryNotification: 'Return to the application to confirm installation and restart.',
|
||||
} as const
|
||||
|
||||
@@ -221,17 +221,17 @@ export const zh = {
|
||||
updateCheckFailed: '检查更新失败,请稍后重试。',
|
||||
updateDownloadFailed: '下载更新失败,请重试。',
|
||||
updateInstallFailed: '安装更新失败,请稍后重试。',
|
||||
updateCheckNetworkFailed: '检查更新失败,请稍后重试。网络连接异常,请检查网络后重试。',
|
||||
updateDownloadNetworkFailed: '下载更新失败,请重试。网络连接异常,请检查网络后重试。',
|
||||
updateInstallNetworkFailed: '安装更新失败,请稍后重试。网络连接异常,请检查网络后重试。',
|
||||
updateCheckNetworkFailed: '检查更新失败,请检查网络连接后重试。',
|
||||
updateDownloadNetworkFailed: '下载更新失败,请检查网络连接后重试。',
|
||||
updateInstallNetworkFailed: '安装更新失败,请检查网络连接后重试。',
|
||||
unknownError: '未知错误',
|
||||
updateCheckTitle: '检查更新',
|
||||
updateCurrent: '当前暂无可用更新。当前版本:V{version}',
|
||||
updateCurrentDetail: '当前版本:{version}',
|
||||
updateCurrent: '已是最新版本',
|
||||
updateChecking: '正在检查更新…',
|
||||
updateDownload: '下载更新',
|
||||
updateDownloadedTitle: 'DeepSeek Harness v{version} 下载完成',
|
||||
updateDownloadedDetail: '安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。',
|
||||
updateDownloadedTitleWindows: '新版本 v{version} 已准备就绪',
|
||||
updateDownloadedTitle: '新版本 {version} 已准备就绪',
|
||||
updateDownloadedDetail: '更新期间应用将暂时关闭,完成后会自动打开。',
|
||||
updateDownloadedDetailWindows: '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。',
|
||||
updateClose: '关闭',
|
||||
updateAcknowledge: '确定',
|
||||
@@ -241,47 +241,47 @@ export const zh = {
|
||||
updateInstalling: '正在准备重启…',
|
||||
updateRetry: '重试更新',
|
||||
updateActiveTasks: '仍有进行中的任务',
|
||||
updateActiveTasksDetail: '重启更新可能中断这些任务,是否要继续更新?',
|
||||
updateActiveTasksDetail: '更新将停止进行中的任务并重启应用,是否继续?',
|
||||
updateStopTasks: '停止任务并更新',
|
||||
updateTasksChanged: '有新任务开始,请重新确认更新。',
|
||||
updateTasksChanged: '有新任务开始运行,请重新确认是否停止任务并更新。',
|
||||
updateTasksUnavailable: '无法确认任务状态,请在工作区就绪后重试更新。',
|
||||
updateStopFailed: '未能安全停止任务,更新未安装。请稍后重试。',
|
||||
updateStopFailed: '未能安全停止任务,更新尚未安装,请稍后重试。',
|
||||
updateTechnicalDetails: '查看技术详情',
|
||||
updateTitle: 'DeepSeek Harness 更新',
|
||||
updateAvailable: '发现可用更新',
|
||||
updateDetail: 'DeepSeek Harness {version}\n\nDeepSeek Harness 将重启以完成更新。',
|
||||
updateAvailable: '发现新版本 {version}',
|
||||
updateDetail: '下载完成后,可安装并重启应用。',
|
||||
installAndRestart: '安装并重启',
|
||||
later: '稍后',
|
||||
updateFailedTitle: '更新失败',
|
||||
mandatoryTitle: '需要更新',
|
||||
mandatoryDetail: '当前版本已停止支持,请更新后继续使用。在您确认重启之前,现有任务可以继续运行。',
|
||||
mandatoryUnavailable: '暂时无法检查更新要求,请在网络恢复后重试。',
|
||||
mandatoryTitle: '请更新后继续使用',
|
||||
mandatoryDetail: '当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。',
|
||||
mandatoryUnavailable: '暂时无法检查更新要求,请稍后重试。',
|
||||
policyLoginTitle: '登录测试环境',
|
||||
policyLoginRequired: '这是测试版应用,检查更新要求需要先通过飞书登录。登录不会下载或安装更新。',
|
||||
policyLoginRequired: '此测试版需要先通过飞书登录,才能检查更新要求。登录不会下载或安装更新。',
|
||||
policyLogin: '通过飞书登录',
|
||||
policyLoginFailed: '测试环境登录未完成,请检查网络后重试。',
|
||||
policyLoginFailed: '飞书登录未完成,请重试。',
|
||||
policyLoginLoading: '正在加载登录页面…',
|
||||
mandatoryNoRelease: '暂时没有可用的更新,请重新检查或联系支持人员。',
|
||||
mandatoryNoRelease: '暂未找到适用的更新,请重新检查或联系支持人员。',
|
||||
mandatoryRefresh: '重新检查',
|
||||
mandatoryPage: '前往官网下载',
|
||||
mandatoryCopy: '复制下载链接',
|
||||
mandatoryPageFailed: '无法打开浏览器,请复制下载链接后手动打开。',
|
||||
mandatoryActionFailed: '更新操作失败,请重试;应用仍需更新后才能继续使用。',
|
||||
mandatoryPageFailed: '无法打开官网下载页面,请复制链接后在浏览器中打开。',
|
||||
mandatoryActionFailed: '操作失败,请重试。完成更新后才能继续使用应用。',
|
||||
mandatoryReady: '更新已准备就绪',
|
||||
mandatoryVersion: 'V{version}',
|
||||
mandatoryReadyDetail: '安装后将重新启动应用。',
|
||||
mandatoryDeferred: '现有任务可以继续运行。完成更新后才能继续操作应用。',
|
||||
mandatoryContinue: '继续安装更新',
|
||||
mandatoryVersion: '新版本:{version}',
|
||||
mandatoryReadyDetail: '更新期间应用将暂时关闭,完成后会自动打开。',
|
||||
mandatoryDeferred: '进行中的任务会继续运行。请完成更新后再操作应用。',
|
||||
mandatoryContinue: '继续更新',
|
||||
mandatoryInspecting: '正在检查任务状态…',
|
||||
mandatoryStopping: '正在安全结束应用中的任务。',
|
||||
mandatoryStopping: '正在停止任务…',
|
||||
mandatoryRestarting: '应用即将重启,请稍候。',
|
||||
mandatoryDownloadFailed: '更新文件下载或准备失败,请重试。',
|
||||
mandatoryInstallFailed: '更新尚未安装,请重新检查任务后重试。',
|
||||
mandatoryOpenHelp: '若页面未打开,可',
|
||||
mandatoryReopen: '重新打开官网',
|
||||
mandatoryCopied: '已复制链接',
|
||||
mandatoryCopyFailed: '复制失败,请手动选择下方地址复制。',
|
||||
mandatoryAddress: '下载地址',
|
||||
mandatoryOpenHelp: '页面未打开?',
|
||||
mandatoryReopen: '重新前往官网下载',
|
||||
mandatoryCopied: '链接已复制',
|
||||
mandatoryCopyFailed: '复制失败,请手动选择并复制下方链接。',
|
||||
mandatoryAddress: '下载链接',
|
||||
mandatoryNotification: '返回应用确认安装并重启。',
|
||||
} as const satisfies DesktopMessages
|
||||
|
||||
@@ -325,7 +325,7 @@ export function formatDesktopMessage(
|
||||
/**
|
||||
* Select localized copy for an ordinary downloaded-update confirmation.
|
||||
* @param messages - Selected Desktop dictionary.
|
||||
* @param version - Prepared update version without a leading v.
|
||||
* @param version - Prepared update version, including any prerelease suffix.
|
||||
* @param platform - Operating system presenting the confirmation.
|
||||
* @returns The versioned title and installation guidance.
|
||||
*/
|
||||
@@ -335,7 +335,7 @@ export function desktopUpdateReadyConfirmation(
|
||||
platform: string,
|
||||
): { message: string; detail: string } {
|
||||
return {
|
||||
message: formatDesktopMessage(platform === 'win32' ? messages.updateDownloadedTitleWindows : messages.updateDownloadedTitle, { version }),
|
||||
message: formatDesktopMessage(messages.updateDownloadedTitle, { version }),
|
||||
detail: platform === 'win32' ? messages.updateDownloadedDetailWindows : messages.updateDownloadedDetail,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types'
|
||||
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
|
||||
/** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */
|
||||
|
||||
@@ -44,7 +45,7 @@ import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './upd
|
||||
import { desktopUpdateErrorSummary, presentDesktopUpdate } from './update-presentation.ts'
|
||||
import { desktopErrorState } from './startup-error.ts'
|
||||
import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopPolicyState } from './mandatory-update-policy.ts'
|
||||
import { desktopClientMetadata } from './client-metadata.ts'
|
||||
import { desktopClientMetadata, desktopClientVersion } from './client-metadata.ts'
|
||||
import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts'
|
||||
import { DesktopPolicyTestAuth } from './policy-test-auth.ts'
|
||||
import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts'
|
||||
@@ -376,6 +377,14 @@ async function main(): Promise<void> {
|
||||
const browserGuests = new DesktopBrowserGuests(() => hostUrl)
|
||||
let injections: readonly unknown[] = []
|
||||
let welcomeBackend: DesktopWelcomeBackend | undefined
|
||||
let reportedLaunch = false
|
||||
let analyticsEnabled = false
|
||||
const track = async <K extends keyof ProductEventMap>(eventName: K, attributes: ProductEventMap[K]): Promise<void> => {
|
||||
const event = { eventName, attributes, timestamp: Date.now() } as ProductEvent
|
||||
try {
|
||||
if (analyticsEnabled) await welcomeBackend?.report(event)
|
||||
} catch (_error) { /* Analytics cannot interrupt native actions. */ }
|
||||
}
|
||||
let stopAccount: (() => void) | undefined
|
||||
let openedAttempt: string | undefined
|
||||
let returnedAttempt: string | undefined
|
||||
@@ -408,7 +417,7 @@ async function main(): Promise<void> {
|
||||
const backend = new DesktopBackendController((onFailure) => {
|
||||
const hostInspectPort = developmentHostInspectPort(development)
|
||||
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
|
||||
hostInspectPort, process.env, onFailure,
|
||||
hostInspectPort, { ...process.env, DSH_CLIENT_VERSION: desktopClientVersion() }, onFailure,
|
||||
primaryRuntime,
|
||||
resources, (next) => { platformView.setSession(next) })
|
||||
return {
|
||||
@@ -419,6 +428,8 @@ async function main(): Promise<void> {
|
||||
if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections')
|
||||
injections = ready.injections
|
||||
welcomeBackend = await connectDesktopWelcome(ready.url, (input, init) => net.fetch(input, init), async () => (await session.defaultSession.cookies.get({ url: ready.url })).map(cookie => `${cookie.name}=${cookie.value}`).join('; '))
|
||||
analyticsEnabled = await welcomeBackend.analyticsEnabled().catch(() => false)
|
||||
if (!reportedLaunch) { reportedLaunch = true; void track('desktop_app_launch', {}) }
|
||||
stopAccount?.()
|
||||
const accountBackend = welcomeBackend.account
|
||||
stopAccount = accountBackend.watch((state) => {
|
||||
@@ -456,9 +467,11 @@ async function main(): Promise<void> {
|
||||
const state = await accountBackend.state()
|
||||
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state)
|
||||
}).catch(() => undefined)
|
||||
})
|
||||
}, (enabled) => { analyticsEnabled = enabled })
|
||||
},
|
||||
stop: async () => {
|
||||
analyticsEnabled = false
|
||||
stopAccount?.()
|
||||
try { await host.stop(requireCleanStop) }
|
||||
catch (error) {
|
||||
if (!requireCleanStop || !(error instanceof DesktopHostUncleanExitError)) throw error
|
||||
@@ -567,6 +580,8 @@ async function main(): Promise<void> {
|
||||
const result = await updateDialog.show(parent, confirmation)
|
||||
if (result.response !== 0 || isMandatory()) return false
|
||||
}
|
||||
// The update lock rejects new HTTP requests, including analytics intake.
|
||||
await track('desktop_upgrade_install_restart_click', {})
|
||||
if (backend.host !== host) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable)
|
||||
try {
|
||||
const stillActive = await host.updateTasks('lock')
|
||||
@@ -591,11 +606,14 @@ async function main(): Promise<void> {
|
||||
}
|
||||
return true
|
||||
},
|
||||
undefined, undefined, undefined,
|
||||
(success, reason) => { void track('desktop_upgrade_download_result', { is_success: success, ...reason === undefined ? {} : { error_reason: reason } }) },
|
||||
)
|
||||
|
||||
const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env))
|
||||
|
||||
const downloadUpdate = async (version: string): Promise<DesktopUpdateState> => {
|
||||
void track('desktop_upgrade_click', {})
|
||||
updateJournal?.action('download-requested')
|
||||
const state = await updates.download(version)
|
||||
if (state.phase !== 'ready' || quitting) return state
|
||||
@@ -778,7 +796,8 @@ async function main(): Promise<void> {
|
||||
if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return }
|
||||
if (state.phase === 'idle') {
|
||||
await ordinaryMessageBox({ type: 'info', title: locale.messages.updateCheckTitle,
|
||||
message: formatDesktopMessage(locale.messages.updateCurrent, { version: app.getVersion() }) })
|
||||
message: locale.messages.updateCurrent,
|
||||
detail: formatDesktopMessage(locale.messages.updateCurrentDetail, { version: app.getVersion() }) })
|
||||
return
|
||||
}
|
||||
if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) {
|
||||
@@ -790,8 +809,9 @@ async function main(): Promise<void> {
|
||||
}
|
||||
if (state.phase !== 'available' && !(state.phase === 'error' && state.failedOperation === 'download')) return
|
||||
if (manual) {
|
||||
const result = await ordinaryMessageBox({ title: locale.messages.updateCheckTitle, message: locale.messages.updateAvailable,
|
||||
detail: formatDesktopMessage(locale.messages.updateDetail, { version: state.version ?? '' }),
|
||||
const result = await ordinaryMessageBox({ title: locale.messages.updateCheckTitle,
|
||||
message: formatDesktopMessage(locale.messages.updateAvailable, { version: state.version ?? '' }),
|
||||
detail: locale.messages.updateDetail,
|
||||
buttons: [locale.messages.updateDownload], cancelId: 1 })
|
||||
if (result.response !== 0) return
|
||||
}
|
||||
@@ -1083,12 +1103,15 @@ async function main(): Promise<void> {
|
||||
const showWelcome = (): Promise<void> => {
|
||||
if (quitting) return Promise.resolve()
|
||||
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) {
|
||||
if (!welcomeWindow.isVisible()) void track('auth_page_view', {})
|
||||
welcomeWindow.show()
|
||||
welcomeWindow.focus()
|
||||
return Promise.resolve()
|
||||
}
|
||||
openingWelcome ??= (async () => {
|
||||
welcomeWindow = await openWelcomeWindow(locale, {
|
||||
analytics: track,
|
||||
analyticsEnabled: () => Promise.resolve(analyticsEnabled),
|
||||
takeNotice: () => {
|
||||
const notice = pendingWelcomeNotice
|
||||
pendingWelcomeNotice = undefined
|
||||
|
||||
@@ -243,7 +243,11 @@ export class DesktopMandatoryUpdateWindow {
|
||||
}
|
||||
|
||||
private view(): MandatoryUpdateView {
|
||||
return { locale: this.options.locale, policy: this.options.policy(), update: this.options.update(),
|
||||
const locale = process.platform === 'win32'
|
||||
? { ...this.options.locale, messages: { ...this.options.locale.messages,
|
||||
mandatoryReadyDetail: this.options.locale.messages.updateDownloadedDetailWindows } }
|
||||
: this.options.locale
|
||||
return { locale, policy: this.options.policy(), update: this.options.update(),
|
||||
deferred: this.deferred,
|
||||
...(this.confirmation === undefined ? {}
|
||||
: { confirmation: { version: this.confirmation.version, active: this.confirmation.active, revision: this.confirmation.revision } }),
|
||||
|
||||
@@ -10,6 +10,10 @@ const locale = process.argv.find(argument => argument.startsWith(prefix))?.slice
|
||||
if (locale === undefined) throw new Error('desktop welcome: missing window locale')
|
||||
const api: WelcomeApi = {
|
||||
...resolveDesktopLocale(locale),
|
||||
analytics: async (eventName, attributes) => {
|
||||
if (await api.analyticsEnabled()) await ipcRenderer.invoke(WELCOME_IPC.analytics, eventName, attributes)
|
||||
},
|
||||
analyticsEnabled: () => ipcRenderer.invoke(WELCOME_IPC.analyticsEnabled) as Promise<boolean>,
|
||||
takeNotice: () => ipcRenderer.invoke(WELCOME_IPC.takeNotice) as Promise<WelcomeNotice | undefined>,
|
||||
startSignIn: () => ipcRenderer.invoke(WELCOME_IPC.start) as Promise<AccountView>,
|
||||
cancelSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.cancel, id) as Promise<AccountView>,
|
||||
|
||||
@@ -45,6 +45,7 @@ export class DesktopUpdateCoordinator {
|
||||
* @param updater - Process-owned Electron updater, replaceable at the network/platform test boundary.
|
||||
* @param enabled - Whether this process has a packaged update source.
|
||||
* @param currentVersion - Actual installed application version.
|
||||
* @param downloadResult - Once per completed download attempt, including platform preparation failures.
|
||||
*/
|
||||
constructor(
|
||||
private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState,
|
||||
@@ -52,6 +53,7 @@ export class DesktopUpdateCoordinator {
|
||||
private readonly updater: AppUpdater = autoUpdater,
|
||||
private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')),
|
||||
private readonly currentVersion: () => string = () => app.getVersion(),
|
||||
private readonly downloadResult?: (success: boolean, reason?: string) => void,
|
||||
) {
|
||||
if (updater === autoUpdater) {
|
||||
// electron-updater omits this internal transport property from its public declarations.
|
||||
@@ -108,9 +110,11 @@ export class DesktopUpdateCoordinator {
|
||||
try {
|
||||
await this.updater.downloadUpdate()
|
||||
if (!this.downloaded) throw new Error('desktop update: platform preparation did not report readiness')
|
||||
this.downloadResult?.(true)
|
||||
return this.setState({ phase: 'ready', version })
|
||||
} catch (error) {
|
||||
this.downloaded = false
|
||||
this.downloadResult?.(false, error instanceof DesktopUpdatePreparationError ? error.kind : 'download_failed')
|
||||
return this.setState(this.failure(error, 'download'))
|
||||
}
|
||||
}).finally(() => { this.downloadOperation = undefined })
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/** Operations available to the isolated native welcome renderer. */
|
||||
|
||||
import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type { ProductEventMap } from '@deepseek-ai/dsh-client-product-analytics/types'
|
||||
import type { DesktopLocale } from './locale.ts'
|
||||
|
||||
/** Private native welcome channels, installed only while its window exists. */
|
||||
export const WELCOME_IPC = {
|
||||
saveApiKey: 'dsh-welcome:save-api-key',
|
||||
analytics: 'dsh-welcome:analytics',
|
||||
analyticsEnabled: 'dsh-welcome:analytics-enabled',
|
||||
skip: 'dsh-welcome:skip',
|
||||
start: 'dsh-welcome:start',
|
||||
cancel: 'dsh-welcome:cancel',
|
||||
@@ -20,8 +23,14 @@ export type WelcomeSaveResult = { readonly ok: true } | { readonly ok: false }
|
||||
/** One-time notification retained by the main process until Welcome receives it. */
|
||||
export type WelcomeNotice = 'session-expired'
|
||||
|
||||
type WelcomeEventName = 'auth_page_view' | 'auth_page_click' | 'api_key_save_click'
|
||||
|
||||
/** Host-owned operations used by the welcome window. */
|
||||
export interface WelcomeOperations {
|
||||
/** @param eventName - allowed welcome event. @param attributes - approved fields without credentials. */
|
||||
analytics?<K extends WelcomeEventName>(eventName: K, attributes: ProductEventMap[K]): Promise<void>
|
||||
/** @returns the Host's current effective collection policy. */
|
||||
analyticsEnabled(): Promise<boolean>
|
||||
/** @returns the pending notification, clearing it before another renderer can receive it. */
|
||||
takeNotice(): Promise<WelcomeNotice | undefined>
|
||||
/** @returns account state after starting a login attempt. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types'
|
||||
/** Native welcome operations using the shared Web authentication and RPC APIs. */
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
@@ -13,7 +14,11 @@ export interface WelcomeState {
|
||||
|
||||
/** Narrow operations available to the native welcome flow. */
|
||||
export interface DesktopWelcomeBackend {
|
||||
/** @returns the current Host policy; every read observes live configuration. */
|
||||
analyticsEnabled(): Promise<boolean>
|
||||
readonly account: DesktopAccountBackend
|
||||
/** @param event - desktop-owned fields. @returns after local Host intake. */
|
||||
report(event: ProductEvent): Promise<void>
|
||||
/** @returns Configured-key presence and the shared language preference, without credential values. */
|
||||
read(): Promise<WelcomeState>
|
||||
/** @returns The saved UI language without account or provider requests. */
|
||||
@@ -44,11 +49,14 @@ export async function connectDesktopWelcome(
|
||||
const authenticated = await send(authenticatedUrl, { credentials: 'include' })
|
||||
await authenticated.body?.cancel()
|
||||
if (!authenticated.ok) throw new Error('desktop welcome: Web authentication failed')
|
||||
const invoke = async (request: { namespace: string; method: string; args: Record<string, unknown> }): Promise<unknown> => {
|
||||
const invoke = async (
|
||||
request: { namespace: string; method: string; args: Record<string, unknown> },
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> => {
|
||||
const rpcId = randomUUID()
|
||||
const method = `${request.namespace}/${request.method}`
|
||||
const response = await send(new URL(`/api/${method}`, origin).href, {
|
||||
method: 'POST', credentials: 'include', redirect: 'error',
|
||||
method: 'POST', credentials: 'include', redirect: 'error', ...(signal === undefined ? {} : { signal }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: request.args } }),
|
||||
})
|
||||
@@ -115,6 +123,12 @@ export async function connectDesktopWelcome(
|
||||
return {
|
||||
account,
|
||||
read,
|
||||
async analyticsEnabled() {
|
||||
const enabled = await invoke({ namespace: 'productAnalytics', method: 'enabled', args: {} }, AbortSignal.timeout(1000))
|
||||
if (typeof enabled !== 'boolean') throw new Error('desktop analytics: invalid collection policy')
|
||||
return enabled
|
||||
},
|
||||
async report(event) { await invoke({ namespace: 'productAnalytics', method: 'report', args: { event } }, AbortSignal.timeout(1000)) },
|
||||
async readLocalePreference() {
|
||||
const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} })
|
||||
if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces')
|
||||
|
||||
@@ -57,14 +57,16 @@ let disposeActiveHandlers: (() => void) | undefined
|
||||
* @returns the visible window; a failed load destroys it before rejecting.
|
||||
*/
|
||||
export async function openWelcomeWindow(locale: DesktopLocale, operations: WelcomeOperations): Promise<BrowserWindow> {
|
||||
const window = new BrowserWindow(welcomeWindowOptions(process.platform, locale))
|
||||
const options = welcomeWindowOptions(process.platform, locale)
|
||||
const window = new BrowserWindow(options)
|
||||
disposeActiveHandlers?.()
|
||||
let active = true
|
||||
const disposeHandlers = (): void => {
|
||||
if (!active) return
|
||||
active = false
|
||||
for (const channel of [
|
||||
WELCOME_IPC.takeNotice, WELCOME_IPC.saveApiKey, WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink,
|
||||
WELCOME_IPC.analyticsEnabled, WELCOME_IPC.analytics, WELCOME_IPC.takeNotice, WELCOME_IPC.saveApiKey,
|
||||
WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink,
|
||||
]) {
|
||||
ipcMain.removeHandler(channel)
|
||||
}
|
||||
@@ -76,6 +78,20 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco
|
||||
throw new Error('desktop welcome: rejected action from an unowned frame')
|
||||
}
|
||||
}
|
||||
ipcMain.handle(WELCOME_IPC.analyticsEnabled, (event) => {
|
||||
assertSender(event)
|
||||
return operations.analyticsEnabled()
|
||||
})
|
||||
ipcMain.handle(WELCOME_IPC.analytics, async (event, eventName: unknown, attributes: unknown) => {
|
||||
assertSender(event)
|
||||
if (typeof attributes !== 'object' || attributes === null || Array.isArray(attributes)) throw new Error('desktop welcome: invalid analytics attributes')
|
||||
if (eventName === 'auth_page_click' && 'button_name' in attributes && Object.keys(attributes).length === 1
|
||||
&& (attributes.button_name === 'sign_in' || attributes.button_name === 'api-key')) {
|
||||
await operations.analytics?.(eventName, { button_name: attributes.button_name })
|
||||
} else if ((eventName === 'auth_page_view' || eventName === 'api_key_save_click') && Object.keys(attributes).length === 0) {
|
||||
await operations.analytics?.(eventName, {})
|
||||
} else throw new Error('desktop welcome: invalid analytics event')
|
||||
})
|
||||
ipcMain.handle(WELCOME_IPC.takeNotice, async (event) => { assertSender(event); return operations.takeNotice() })
|
||||
ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => {
|
||||
assertSender(event)
|
||||
@@ -108,6 +124,9 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco
|
||||
throw error
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Another window can replace ownership during loadFile.
|
||||
if (active && !window.isDestroyed()) window.show()
|
||||
if (active && !window.isDestroyed()) {
|
||||
window.show()
|
||||
void operations.analytics?.('auth_page_view', {})
|
||||
}
|
||||
return window
|
||||
}
|
||||
@@ -49,14 +49,17 @@ it('receives live expiry separately from account snapshots', async () => {
|
||||
if (typeof address === 'string' || address === null) throw new Error('missing server address')
|
||||
const connected = once(server, 'connection')
|
||||
const expired = vi.fn()
|
||||
const onAnalyticsEnabledChanged = vi.fn()
|
||||
const snapshot = { status: 'signed-out', attempt: null,
|
||||
links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }
|
||||
const received = Promise.withResolvers<undefined>()
|
||||
const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), () => Promise.resolve(''))
|
||||
const stop = backend.watch((value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired)
|
||||
const stop = backend.watch(
|
||||
(value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired, onAnalyticsEnabledChanged,
|
||||
)
|
||||
onTestFinished(stop)
|
||||
await connected
|
||||
// Both logical streams share the socket; observe their opening frames before sending data.
|
||||
// Logical streams share the socket; observe their opening frames before sending data.
|
||||
const streams = new Map<string, string>()
|
||||
const opened = Promise.withResolvers<undefined>()
|
||||
const socket = [...server.clients][0]!
|
||||
@@ -64,11 +67,19 @@ it('receives live expiry separately from account snapshots', async () => {
|
||||
if (!Buffer.isBuffer(data)) throw new Error('expected a Buffer WebSocket frame')
|
||||
const frame = JSON.parse(data.toString('utf8')) as { endpoint: string; streamId: string }
|
||||
streams.set(frame.endpoint, frame.streamId)
|
||||
if (streams.size === 2) opened.resolve(undefined)
|
||||
if (streams.size === 3) opened.resolve(undefined)
|
||||
})
|
||||
await opened.promise
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watch'), value: snapshot }))
|
||||
await received.promise
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: true }))
|
||||
await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(true) })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: false }))
|
||||
await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(false) })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: true }))
|
||||
await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(true) })
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: 'invalid policy' }))
|
||||
await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(false) })
|
||||
expect(expired).not.toHaveBeenCalled()
|
||||
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watchExpiry'), value: 'session-expired' }))
|
||||
await vi.waitFor(() => { expect(expired).toHaveBeenCalledOnce() })
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"title": "仍有进行中的任务",
|
||||
"detail": "重启更新可能中断这些任务,是否要继续更新?",
|
||||
"detail": "更新将停止进行中的任务并重启应用,是否继续?",
|
||||
"status": "",
|
||||
"version": "V1.0.1-nightly.1",
|
||||
"version": "新版本:1.0.1-nightly.1",
|
||||
"buttons": [
|
||||
"停止任务并更新",
|
||||
"稍后更新"
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"returned": [
|
||||
"Checking for updates…",
|
||||
"No updates available. Current version: V1.0.0",
|
||||
"This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update."
|
||||
"You’re up to date!",
|
||||
"Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update."
|
||||
],
|
||||
"cancelled": [
|
||||
"Checking for updates…",
|
||||
"No updates available. Current version: V1.0.0",
|
||||
"This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update."
|
||||
"You’re up to date!",
|
||||
"Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update."
|
||||
],
|
||||
"failed": [
|
||||
"Checking for updates…",
|
||||
"No updates available. Current version: V1.0.0",
|
||||
"This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update.",
|
||||
"Test environment sign-in did not complete. Please check your connection and try again."
|
||||
"You’re up to date!",
|
||||
"Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update.",
|
||||
"Feishu sign-in did not complete. Please try again."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
[
|
||||
{
|
||||
"title": "Update to continue",
|
||||
"detail": "This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.",
|
||||
"version": "New version: 0.1.7-alpha.2",
|
||||
"update": "Download update"
|
||||
},
|
||||
{
|
||||
"title": "Update ready",
|
||||
"detail": "The app will close during the update and reopen automatically when it is complete.",
|
||||
"version": "New version: 0.1.7-alpha.2",
|
||||
"update": "Install and Restart"
|
||||
},
|
||||
{
|
||||
"title": "Tasks are still in progress",
|
||||
"detail": "Updating will stop the tasks in progress and restart the app. Continue?",
|
||||
"version": "New version: 0.1.7-alpha.2",
|
||||
"update": "Stop tasks and update",
|
||||
"later": "Update later"
|
||||
},
|
||||
{
|
||||
"title": "Update ready",
|
||||
"detail": "Tasks in progress will keep running. Complete the update before using the app again.",
|
||||
"version": "New version: 0.1.7-alpha.2",
|
||||
"update": "Continue update"
|
||||
},
|
||||
{
|
||||
"title": "Preparing to restart…",
|
||||
"detail": "Stopping tasks…",
|
||||
"version": "New version: 0.1.7-alpha.2"
|
||||
},
|
||||
{
|
||||
"title": "Update to continue",
|
||||
"detail": "This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.",
|
||||
"version": "New version: 0.1.7-alpha.2",
|
||||
"error": "The update has not been installed. Check the tasks again and retry.",
|
||||
"update": "Retry update",
|
||||
"page": "Download from the official website"
|
||||
},
|
||||
{
|
||||
"title": "Update to continue",
|
||||
"detail": "This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.",
|
||||
"version": "",
|
||||
"error": "No compatible update was found. Check again or contact support.",
|
||||
"page": "Open the official download page again",
|
||||
"browser-message": "Could not open the official download page. Copy the link and open it in your browser.",
|
||||
"copy-message": "Could not copy the link. Select and copy it below."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
[
|
||||
{
|
||||
"title": "请更新后继续使用",
|
||||
"detail": "当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。",
|
||||
"version": "新版本:0.1.7-alpha.2",
|
||||
"update": "下载更新"
|
||||
},
|
||||
{
|
||||
"title": "更新已准备就绪",
|
||||
"detail": "更新期间应用将暂时关闭,完成后会自动打开。",
|
||||
"version": "新版本:0.1.7-alpha.2",
|
||||
"update": "安装并重启"
|
||||
},
|
||||
{
|
||||
"title": "仍有进行中的任务",
|
||||
"detail": "更新将停止进行中的任务并重启应用,是否继续?",
|
||||
"version": "新版本:0.1.7-alpha.2",
|
||||
"update": "停止任务并更新",
|
||||
"later": "稍后更新"
|
||||
},
|
||||
{
|
||||
"title": "更新已准备就绪",
|
||||
"detail": "进行中的任务会继续运行。请完成更新后再操作应用。",
|
||||
"version": "新版本:0.1.7-alpha.2",
|
||||
"update": "继续更新"
|
||||
},
|
||||
{
|
||||
"title": "正在准备重启…",
|
||||
"detail": "正在停止任务…",
|
||||
"version": "新版本:0.1.7-alpha.2"
|
||||
},
|
||||
{
|
||||
"title": "请更新后继续使用",
|
||||
"detail": "当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。",
|
||||
"version": "新版本:0.1.7-alpha.2",
|
||||
"error": "更新尚未安装,请重新检查任务后重试。",
|
||||
"update": "重试更新",
|
||||
"page": "前往官网下载"
|
||||
},
|
||||
{
|
||||
"title": "请更新后继续使用",
|
||||
"detail": "当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。",
|
||||
"version": "",
|
||||
"error": "暂未找到适用的更新,请重新检查或联系支持人员。",
|
||||
"page": "重新前往官网下载",
|
||||
"browser-message": "无法打开官网下载页面,请复制链接后在浏览器中打开。",
|
||||
"copy-message": "复制失败,请手动选择并复制下方链接。"
|
||||
}
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"message": "DeepSeek Harness v0.1.99 downloaded",
|
||||
"detail": "The update package has downloaded. Select “Install and Restart” to restart the app and begin installation.",
|
||||
"message": "Version 0.1.7-alpha.2 is ready to install",
|
||||
"detail": "The app will close during the update and reopen automatically when it is complete.",
|
||||
"buttons": [
|
||||
"Install and Restart"
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"message": "DeepSeek Harness v0.1.99 下载完成",
|
||||
"detail": "安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。",
|
||||
"message": "新版本 0.1.7-alpha.2 已准备就绪",
|
||||
"detail": "更新期间应用将暂时关闭,完成后会自动打开。",
|
||||
"buttons": [
|
||||
"安装并重启"
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"message": "New version v0.1.99 is ready",
|
||||
"message": "Version 0.1.7-alpha.2 is ready to install",
|
||||
"detail": "The app will close temporarily during the update and reopen automatically when it is complete.\n\nThe update may take some time. Please wait and do not launch the app again during installation.",
|
||||
"buttons": [
|
||||
"Install and Restart"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"message": "新版本 v0.1.99 已准备就绪",
|
||||
"message": "新版本 0.1.7-alpha.2 已准备就绪",
|
||||
"detail": "更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。",
|
||||
"buttons": [
|
||||
"安装并重启"
|
||||
|
||||
+2
-2
@@ -218,7 +218,7 @@ async function qualify() {
|
||||
const checking = await dialogWith(messages.updateChecking)
|
||||
await screenshot(checking, 'checking.png')
|
||||
server.release()
|
||||
const current = await dialogWith(messages.updateCurrent.replace('{version}', app.getVersion()))
|
||||
const current = await dialogWith(messages.updateCurrent)
|
||||
assert.equal(current, checking)
|
||||
await clickText(current, messages.updateAcknowledge)
|
||||
assert.equal(server.requests.filter(path => path === '/payload.exe').length, 0)
|
||||
@@ -227,7 +227,7 @@ async function qualify() {
|
||||
|
||||
server.select('healthy', '0.1.6-nightly.1')
|
||||
checkMenu.click()
|
||||
const available = await dialogWith(messages.updateAvailable)
|
||||
const available = await dialogWith(messages.updateAvailable.replace('{version}', '0.1.6-nightly.1'))
|
||||
server.select('corrupt', '0.1.6-nightly.1')
|
||||
await clickText(available, messages.updateDownload)
|
||||
await waitFor(() => fixture.coordinator.state.phase === 'error', 'checksum failure')
|
||||
|
||||
@@ -172,6 +172,9 @@ const harness = await vi.hoisted(async () => {
|
||||
}),
|
||||
})
|
||||
let accountListener: ((state: AccountView) => void) | undefined
|
||||
let analyticsEnabled = true
|
||||
let analyticsEnabledListener: ((enabled: boolean) => void) | undefined
|
||||
const analytics = vi.fn(async (_event: unknown) => {})
|
||||
const nativeTheme = { themeSource: 'system', shouldUseDarkColors: false }
|
||||
const trays: FakeTray[] = []
|
||||
class FakeTray extends EventEmitter {
|
||||
@@ -184,14 +187,21 @@ const harness = await vi.hoisted(async () => {
|
||||
const shellDialog = { isOpen: false, focus: vi.fn() }
|
||||
return {
|
||||
failWindow(error: Error) { windowFailure = error },
|
||||
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, trays, FakeTray, backgroundNotice, shellDialog,
|
||||
windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, analytics,
|
||||
trays, FakeTray, backgroundNotice, shellDialog,
|
||||
menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall,
|
||||
platformDispose,
|
||||
platformCloseAndWait,
|
||||
|
||||
watchAccount: (listener: (state: AccountView) => void) => {
|
||||
get analyticsEnabled() { return analyticsEnabled },
|
||||
set analyticsEnabled(value: boolean) { analyticsEnabled = value; analyticsEnabledListener?.(value) },
|
||||
watchAccount: (
|
||||
listener: (state: AccountView) => void, _failed: () => void, _expired: () => void,
|
||||
onAnalyticsEnabledChanged?: (enabled: boolean) => void,
|
||||
) => {
|
||||
analyticsEnabledListener = onAnalyticsEnabledChanged
|
||||
accountListener = listener
|
||||
return () => { accountListener = undefined }
|
||||
return () => { accountListener = undefined; analyticsEnabledListener = undefined }
|
||||
},
|
||||
publishAccount(state: AccountView) { accountListener?.(state) },
|
||||
ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(),
|
||||
@@ -343,6 +353,8 @@ vi.mock('../src/platform-view.ts', async importOriginal => ({
|
||||
}))
|
||||
vi.mock('../src/welcome-backend.ts', () => ({
|
||||
connectDesktopWelcome: async () => ({
|
||||
analyticsEnabled: async () => harness.analyticsEnabled,
|
||||
report: harness.analytics,
|
||||
readLocalePreference: async () => null,
|
||||
read: async (): Promise<unknown> => (await harness.hosts.at(-1)!.fetch()).json() as Promise<unknown>,
|
||||
save: async () => ({ ok: true }),
|
||||
@@ -396,6 +408,7 @@ beforeEach(() => {
|
||||
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
|
||||
vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined)
|
||||
vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3')
|
||||
harness.analyticsEnabled = true
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -543,7 +556,7 @@ describe('desktop main startup', () => {
|
||||
expect(testAuth.login).toHaveBeenCalledOnce()
|
||||
const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message)
|
||||
expect(messages.filter(message => message === en.policyLoginRequired)).toHaveLength(1)
|
||||
expect(messages).toContain('No updates available. Current version: V1.0.0')
|
||||
expect(messages).toContain('You’re up to date!')
|
||||
} finally {
|
||||
explanation.resolve({ response: 1 })
|
||||
login.resolve('cancelled')
|
||||
@@ -1062,7 +1075,7 @@ describe('desktop main startup', () => {
|
||||
vi.stubGlobal('process', { ...process, platform })
|
||||
vi.spyOn(harness.app, 'getPreferredSystemLanguages').mockReturnValue([language])
|
||||
await readyForUpdate()
|
||||
harness.updateState = { phase: 'ready', version: '0.1.99' }
|
||||
harness.updateState = { phase: 'ready', version: '0.1.7-alpha.2' }
|
||||
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 1 })
|
||||
await expect(harness.prepareUpdate()).resolves.toBe(false)
|
||||
const { message, detail, buttons } = harness.dialog.showMessageBox.mock.lastCall![0] as MessageBoxOptions
|
||||
@@ -1441,7 +1454,7 @@ describe('desktop main startup', () => {
|
||||
await prompt
|
||||
expect(signal.aborted).toBe(true)
|
||||
expect(harness.dialog.showMessageBox).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
message: 'No updates available. Current version: V1.0.0',
|
||||
message: 'You’re up to date!', detail: 'Current version: 1.0.0',
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -1506,7 +1519,7 @@ describe('desktop main startup', () => {
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(() => policy.promise))
|
||||
harness.updateCheck.mockResolvedValue({ phase: 'available', version: '1.0.1-nightly.1' })
|
||||
harness.dialog.showMessageBox.mockImplementation(({ signal, message }: { signal?: AbortSignal; message: string }) => {
|
||||
if (message !== en.updateAvailable || signal === undefined) return Promise.resolve({ response: 1 })
|
||||
if (message !== en.updateAvailable.replace('{version}', '1.0.1-nightly.1') || signal === undefined) return Promise.resolve({ response: 1 })
|
||||
available.resolve(signal)
|
||||
return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve({ response: 0 }) }, { once: true }) })
|
||||
})
|
||||
@@ -1543,7 +1556,7 @@ describe('desktop main startup', () => {
|
||||
const ordinaryShown = Promise.withResolvers<undefined>()
|
||||
const policyShown = Promise.withResolvers<undefined>()
|
||||
harness.dialog.showMessageBox.mockImplementation(({ message }: { message: string }) => {
|
||||
if (message.startsWith('No updates available.')) {
|
||||
if (message === en.updateCurrent) {
|
||||
ordinaryShown.resolve(undefined)
|
||||
return ordinaryResult.promise
|
||||
}
|
||||
@@ -1638,6 +1651,23 @@ describe('desktop main startup', () => {
|
||||
await expect(preparing).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it.each(['accepted', 'failed'] as const)('settles analytics intake before locking API admission and continues after intake failure: %s', async (outcome) => {
|
||||
const host = await readyForUpdate()
|
||||
await vi.waitFor(() => { expect(harness.analytics).toHaveBeenCalledWith(expect.objectContaining({ eventName: 'desktop_app_launch' })) })
|
||||
const intake = Promise.withResolvers<undefined>()
|
||||
harness.analytics.mockImplementationOnce(() => intake.promise)
|
||||
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
||||
const preparing = harness.prepareUpdate()
|
||||
await vi.waitFor(() => { expect(harness.analytics).toHaveBeenLastCalledWith(expect.objectContaining({ eventName: 'desktop_upgrade_install_restart_click' })) })
|
||||
expect(host.updateTasks.mock.calls).toEqual([['inspect']])
|
||||
expect(host.stop).not.toHaveBeenCalled()
|
||||
if (outcome === 'accepted') intake.resolve(undefined)
|
||||
else intake.reject(new Error('local analytics intake timed out'))
|
||||
await host.stopping.promise
|
||||
host.exited.resolve()
|
||||
await expect(preparing).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('reports a Platform storage cleanup failure as preparation failure without stopping the Host', async () => {
|
||||
const host = await readyForUpdate()
|
||||
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
||||
@@ -2034,7 +2064,9 @@ describe('desktop main startup', () => {
|
||||
primaryRuntime: join('desktop-test-resources', 'runtime', 'primary-runtime'),
|
||||
profile: 'desktop-test-profile',
|
||||
})
|
||||
expect(harness.hosts[0]!.environment).toBe(process.env)
|
||||
expect(harness.hosts[0]!.environment).not.toBe(process.env)
|
||||
expect(harness.hosts[0]!.environment?.DSH_CLIENT_VERSION).toBe('1.2.3')
|
||||
expect(harness.analytics).toHaveBeenCalledExactlyOnceWith({ eventName: 'desktop_app_launch', timestamp: Date.now(), attributes: {} })
|
||||
expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
|
||||
expect(harness.windows).toHaveLength(1)
|
||||
expect(window.urls).toEqual(['dsh-app://app/'])
|
||||
@@ -2159,3 +2191,15 @@ it.each([['light', false], ['dark', true]] as const)('opens Platform authorizati
|
||||
harness.publishAccount(state)
|
||||
expect(harness.openExternal).toHaveBeenCalledExactlyOnceWith(`https://platform.deepseek.com/dsh/authorize?state=state-1&theme=${theme}`)
|
||||
})
|
||||
|
||||
it('disables native product events for a disabled Desktop launch', async () => {
|
||||
harness.analyticsEnabled = false
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
harness.prepared.resolve()
|
||||
await harness.hostStarted.promise
|
||||
harness.hosts[0]!.ready.resolve()
|
||||
await harness.navigated.promise
|
||||
expect(harness.analytics).not.toHaveBeenCalled()
|
||||
harness.analyticsEnabled = true
|
||||
})
|
||||
@@ -205,3 +205,10 @@ it('rebinds policy updates and reloads to a replacement Windows main window', ()
|
||||
sender: window.webContents, senderFrame: window.webContents.mainFrame,
|
||||
})).not.toThrow()
|
||||
})
|
||||
|
||||
it.each(['darwin', 'win32'] as const)('includes the installation wait notice only on Windows: %s', (platform) => {
|
||||
const f = setup(platform)
|
||||
expect(f.view().locale.messages.mandatoryReadyDetail).toBe(platform === 'win32'
|
||||
? '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。'
|
||||
: '更新期间应用将暂时关闭,完成后会自动打开。')
|
||||
})
|
||||
Loaded 100 of 551 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user