Merge remote-tracking branch 'origin/master' into perf/tui-resume-scan

# Conflicts:
#	packages/ui/tui/README.i18n.yaml
This commit is contained in:
Turtle
2026-08-03 13:37:53 +08:00
788 changed files with 30113 additions and 3651 deletions

View File

@@ -1,6 +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-07-20-config-hot-reload-resilience.md: 1a8e29c603ede50b60199e9151fca58dadcc3d40
2026-07-20-config-hot-reload-resilience.zh.md: 6c7a421bfa84504a36d5329e13a485bf72cc6b6c
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-20-config-hot-reload-resilience.md
2026-07-20-config-hot-reload-resilience.md: 0f15bb0aaacb6e06c416cbe35b44155279497eee
2026-07-20-config-hot-reload-resilience.zh.md: 8a185c1915b5247150d8bb1dd5c42d69bd4f2a35

View File

@@ -6,33 +6,36 @@ English | [中文](2026-07-20-config-hot-reload-resilience.zh.md)
## Problem
The demo apps mount `@cordisjs/plugin-hmr` as a leaf so a running agent picks up `cordis.yml` edits. One bad edit killed the process: `Include.refresh()` rethrew the YAML parse error, the HMR watcher awaits `refresh()` inside an async chokidar callback nobody catches, and the resulting unhandled rejection tripped `dsh-app-boot`'s fail-loud handler — `exit(1)` mid-session, losing the live TUI. Two adjacent defects made even *valid* reloads wrong: a file that parses to `undefined` (empty or mid-write truncated — editors and `sed -i` routinely produce these states) crashed the entry walk instead of reading as invalid, and a re-read never re-applied the include's `config.patches`, so any hot-reload of an overlay-based tree (Code Mode, personal overlays) silently reverted patched entries and removed inserted ones.
An invalid `cordis.yml` edit must not kill a running agent, but preserving the process is insufficient when a valid-looking update partially replaces the Loader tree before a later entry fails. Callers also need to observe a rejected live update without treating the same error as an unhandled boot failure. Personal configuration adds a second requirement: HMR must observe one exact file outside its module roots, including a file or parent directory created after startup.
## Decision
Harden the vendored `@cordisjs/plugin-include` (logged as local modification 8 in [vendor/README.md](../../../../vendor/README.md)) rather than the callers:
The vendored Cordis lifecycle and Loader plugins provide an awaited, compensating config transaction, logged as local modifications 6, 8, and 9 in [vendor/README.md](../../../../vendor/README.md).
- `refresh()` awaits the whole read-and-update and catches failures, logs a warning, and keeps the last good entry tree. A hot-reload is advisory; the invariant is that no file state reachable by an editor may take the process down.
- `read()` rejects a non-array parse result with a `TypeError`, folding the `undefined`-parse case into the same "invalid file" signal, and commits `content`/`data` only after a successful parse — so reverting an edit to the exact last good content correctly reads as "unchanged".
- `refresh()` and the `internal/update` listener apply `this.applyPatches(...)` before `root.update()`, restoring parity with `[Service.init]`. `applyPatches` deep-copies the cached parse (`structuredClone`) instead of mutating it, so repeated application converges and removing a patch reverts to the file's own values. The listener uses the incoming config's `patches` and persists that config itself: it vetoes the fiber restart (children update in place), and `Fiber.update` only assigns `this.config` behind `next()`, so without the explicit assignment the next re-read would re-apply the old overlay.
`Fiber.update()` returns its `internal/update` waterfall result. Config validation remains synchronous, while the default continuation returns the restart promise. Loader entry updates can therefore distinguish validation, import, application, and rollback failure from successful lifecycle settlement. `EntryTree.await()` rechecks service-gated fibers after Loader tasks drain and rejects settled failures; a fiber waiting on an absent service remains a valid pending entry rather than making settlement hang.
Boot-time behavior stays fail-loud and gets a sharper diagnostic: `[Service.init]` falls back to `initial` (or "config file not found") only on `ENOENT`; an existing-but-invalid file now fails with its real parse error instead of being mislabelled as absent or silently overwritten by `initial`.
Loader imports a changed module name before disposing the active fiber. Candidate application is awaited; a failure disposes candidate effects and restores the prior plugin or config. Group reconciliation starts candidates concurrently, awaits every outcome, and restores changed entries, additions, removals, and moves before rejecting. Persistence occurs only after successful programmatic mutation. This is a compensating transaction: lifecycle effects may be briefly visible, and a failed rollback is reported as an `AggregateError` rather than misrepresented as a retained tree.
Include reads and validates detached candidate content, applies patches to a clone, reconciles the Loader tree, and only then commits cached content and parsed data. `refresh()` rejects to its caller after a parse, validation, application, or rollback failure. Initial load remains fail-loud; only an absent file may use `initial`. A non-array YAML/JSON result is invalid, and both file refresh and Include-config update re-apply patches without mutating the cached parse.
HMR contains live refresh rejection. Its `registerConfig(filename, refresh)` method watches one exact path from the nearest existing ancestor, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Both exact-path and ordinary config-file refreshes use that queue. A failure is normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed(filename, error)` event; rejecting observers are logged without stopping later refreshes. Creation, change, and removal are observed.
## Alternatives considered
**Catch in the HMR watcher callback instead of `refresh()`.** Rejected: it would leave `refresh()` a trap for every other caller (the `internal/update` path shares the same tree-update logic), and it cannot fix the `undefined`-parse or patch-loss defects, which live inside the include.
**Contain failures inside `Include.refresh()`.** Rejected because it prevents an HMR host from broadcasting the failure and still permits Loader reconciliation to hide partial application. Include owns candidate parsing and commit; HMR owns containment and observation.
**Filter config-file rejections in `installFailLoud`.** Rejected: the fail-loud handler exists to make late load failures visible; teaching it to classify exceptions by origin would silently swallow genuine boot failures and leave the stale-`data` crash in place.
**Restart the process for every config edit.** Rejected because Cordis effects already provide reversible plugin lifecycle, and a syntax error or failed optional plugin must not discard live sessions merely to recover the prior composition.
**A PTY e2e proving the TUI survives a bad edit.** Rejected as the primary gate: the PTY smoke reads the repo's committed `cordis.yml`, so corrupting it in-place is not test-safe, and a temp copy cannot resolve the tree's bare package specifiers. The unit spec drives the exact `refresh()` entry point the watcher calls; the fix was additionally verified manually against the live TUI (bad YAML, empty file, restored file).
**Promise invisible atomic replacement.** Rejected because arbitrary plugin effects cannot be snapshotted. Awaited application plus explicit compensation provides a stable final result without claiming that observers cannot see intermediate lifecycle transitions.
## Consequences
- A bad `cordis.yml` edit now logs `ignoring config reload at <file>` and the agent keeps running on the last good tree; the next valid edit applies normally. With no logger exporter mounted in the TUI demos the warning is currently invisible on screen — surfacing loader warnings in the TUI is deferred.
- Overlay trees survive base-file reloads with patches intact instead of silently reverting to the unpatched base.
- The vendored include diverges further from upstream; the divergence is logged in the vendor manifest and re-applies on the next sync.
- Known gap, out of scope here: the HMR watcher only handles chokidar `change` events, so editors that replace the file by rename (BSD `sed -i`, `git checkout`) do not trigger a config reload at all; and a reloaded app-entry config does not visibly restart the running TUI (pre-existing on the unmodified tree).
- A failed live refresh rejects internally, retains or restores the last-good tree when compensation succeeds, and broadcasts one typed failure without becoming an unhandled rejection.
- A rollback failure is visible and may leave an entry unavailable; the event and log do not claim otherwise.
- Fibers waiting on declared dependencies remain valid pending entries: lifecycle settlement means no current work failed, not that every dependency exists.
- Exact config watchers add filesystem resources only for registered paths and release them with their owning HMR fiber.
- The vendored Loader, Include, HMR, and core event typing diverge further from upstream; the complete divergence is maintained in the vendor manifest.
## Testing
`packages/ui/app-boot/tests/config-reload.spec.ts` boots real Loader trees against temp configs and pins: an invalid-YAML edit and an empty-file edit both resolve `refresh()` without rejection and keep the previous entry config; a subsequent valid edit applies; an overlay tree re-applies both entry patches and inserted entries on re-read; a hot-update of the include entry's own `patches` applies immediately, survives the next file re-read, and reverts cleanly when the patches are removed. The assertions fail on the unpatched vendored include.
`packages/ui/app-boot/tests/config-reload.spec.ts` boots real temporary Loader/Include trees and covers parse and shape rejection, import-before-dispose, plugin/config restoration, multi-entry rollback, ancestor disablement, overlay convergence, option identity, failed direct-update persistence, and failed programmatic moves. `packages/ui/app-boot/tests/hmr-config.spec.ts` covers existing and missing exact paths, add/change/removal, serialized coalescing, disposal drainage, non-`Error` normalization, failure broadcast, and rejecting-observer containment. `packages/host/webserver/tests/webserver.spec.ts` proves a service-gated startup failure rejects Loader composition with its bind diagnostic, `packages/typert/loader/tests/loader.spec.ts` exercises awaited programmatic removal through a real Loader consumer, and the ACP `pty-tools` snapshot guards concurrent composition from reordering equal-priority prompt sections.

View File

@@ -6,33 +6,36 @@ Status: implemented
## Problem
各示例应用把 `@cordisjs/plugin-hmr` 挂载为叶子配置项,让运行中的 agent 能感知 `cordis.yml` 的编辑。一次错误的编辑就会杀死进程:`Include.refresh()` 把 YAML 解析错误原样抛出HMR 的文件监听器在一个无人捕获的异步 chokidar 回调里 await `refresh()`,产生的未处理 rejection 触发 `dsh-app-boot` 的快速失败处理器——会话中途 `exit(1)`,正在运行的 TUI 就此丢失。另有两个相邻缺陷让*合法*的重载也出错:解析结果为 `undefined` 的文件(空文件或写入中途被截断的文件——编辑器和 `sed -i` 常态性地产生这类中间状态)会让配置项遍历直接崩溃,而不是被判定为无效文件;并且重新读取时从不重新应用 include 的 `config.patches`,因此对基于 overlay 的配置树Code Mode、个人 overlay做任何热重载都会悄悄把打过补丁的配置项回退、并把插入的配置项移除
无效的 `cordis.yml` 编辑不得杀死运行中的 agent智能体但若一次看似有效的更新先部分替换 Loader 树后续配置项才失败仅仅保住进程仍不够。调用方还需要能观察到被拒绝的实时更新同时不能让同一个错误被当作未处理的启动失败。个人配置还带来第二项要求HMR热模块替换必须观察其模块根目录之外的一个确切文件包括启动后才创建的文件或父目录
## Decision
加固 vendor 的 `@cordisjs/plugin-include`在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 8 条),而不是修改调用方:
vendor Cordis 生命周期和 Loader 插件提供可等待、带补偿的配置事务,并在 [vendor/README.md](../../../../vendor/README.md) 中记录为本地修改第 6、8、9 条。
- `refresh()` await 整个「读取并更新」过程并捕获失败,记录一条警告,并保留上一份完好的配置树。热重载是尽力而为的;不变式是编辑器可能产生的任何文件状态都不得导致进程退出
- `read()` 对非数组的解析结果抛出 `TypeError`,把 `undefined` 解析结果并入同一个「无效文件」信号,并且只在解析成功后才提交 `content`/`data`——因此把编辑撤销回与上一份完好内容完全一致时,会正确地判定为「无变化」。
- `refresh()``internal/update` 监听器在 `root.update()` 之前调用 `this.applyPatches(...)`,与 `[Service.init]` 保持一致。`applyPatches` 对缓存的解析结果做深拷贝(`structuredClone`)而不是就地修改,因此重复应用会收敛,移除补丁会回退到文件自身的值。监听器使用传入配置中的 `patches` 并自行持久化该配置:它否决 fiber 重启(子配置项就地更新),而 `Fiber.update` 只在 `next()` 之后才赋值 `this.config`,若不显式赋值,下一次重新读取会重新应用旧的 overlay。
`Fiber.update()` 返回其 `internal/update` waterfall瀑布式事件的结果。配置校验保持同步而默认 continuation 返回重启 promise。因此Loader 配置项更新可以区分校验、导入、应用和回滚失败,以及生命周期成功完成。`EntryTree.await()` 会在 Loader 任务排空后重新检查受服务门控的 fiber并在 fiber 已结算为失败时 reject等待缺失服务的 fiber 仍是有效的 pending 配置项,不会让结算挂起
启动期行为保持快速失败并获得更准确的诊断:`[Service.init]` 只在 `ENOENT` 时回退到 `initial`或「config file not found」存在但无效的文件现在会以真实的解析错误失败而不是被误标为文件缺失、或被 `initial` 静默覆盖
Loader 会先导入变化后的模块名,再 dispose资源释放活动 fiber。它会 await 候选项的应用;若失败,则 dispose 候选项的 effect并恢复先前的插件或配置。组内对账会并发启动各候选项等待每项结果并会在拒绝前恢复已变更的配置项、添加项、移除项和移动项。只有程序化变更成功后才会持久化。这是一种补偿事务生命周期 effect 可能短暂可见;回滚失败会报告为 `AggregateError`,而不会被误称为树已保留
Include 读取并校验尚未提交的候选内容,把补丁应用到其副本,对账 Loader 树,然后才提交缓存内容和解析数据。解析、校验、应用或回滚失败后,`refresh()` 会向调用方 reject。初始加载继续快速失败只有文件不存在时才可以使用 `initial`。YAML/JSON 结果若不是数组即为无效;文件刷新和 Include 配置更新都会重新应用补丁,且不修改缓存的解析结果。
HMR 收容实时刷新 rejection。其 `registerConfig(filename, refresh)` 方法从最近的现有祖先目录开始监听一个确切路径,串行化并合并刷新,并返回一个异步 disposer该 disposer 会关闭 watcher 并排空活跃工作。确切路径和普通配置文件的刷新都使用此队列。失败会被规范化为 `Error`、记入日志,并通过并行事件 `hmr/config-update-failed(filename, error)` 广播;发生 rejection 的观察者会被记录,但不会阻止后续刷新。创建、变更和移除均会被观察。
## Alternatives considered
**在 HMR 监听回调里捕获,而不是在 `refresh()` ** 否决:这会让 `refresh()` 继续成为其他所有调用方的陷阱(`internal/update` 路径共享同一套树更新逻辑),而且无法修复 `undefined` 解析结果与补丁丢失这两个位于 include 内部的缺陷
**在 `Include.refresh()` 内收容失败** 否决,因为这会使 HMR 宿主无法广播失败,却仍允许 Loader 对账掩盖部分应用。Include 负责候选内容的解析与提交HMR 负责收容和观察
**在 `installFailLoud` 里过滤配置文件相关的 rejection** 否决:快速失败处理器的存在意义就是让延迟出现的加载失败可见;教它按来源给异常分类会悄悄吞掉真正的启动失败,并且原样保留陈旧 `data` 导致的崩溃
**每次编辑配置都重启进程** 否决,因为 Cordis effect 已经提供可逆的插件生命周期,而语法错误或可选插件失败不应只为恢复先前的组合就丢弃正在进行的会话
**用 PTY e2e 证明 TUI 能在错误编辑后存活** 否决其作为主要门禁PTY 冒烟测试读取仓库中已提交的 `cordis.yml`,就地破坏它对测试不安全,而临时副本无法解析该配置树的裸包说明符。单元测试直接驱动监听器所调用的 `refresh()` 入口;此外还对运行中的 TUI 做了人工验证(错误 YAML、空文件、恢复文件
**承诺不可见的原子替换** 否决,因为任意插件 effect 无法制作快照。等待应用完成并显式补偿可以得到稳定的最终结果,同时不会声称观察者看不到中间生命周期转换
## Consequences
- 现在错误的 `cordis.yml` 编辑会记录 `ignoring config reload at <file>`agent 继续运行在上一份完好的配置树上下一次合法编辑正常生效。TUI 示例没有挂载任何日志导出器,这条警告目前不会显示在屏幕上——在 TUI 中呈现 loader 警告的工作暂缓
- overlay 配置树在基础文件重载后补丁保持完整,不再悄悄回退到未打补丁的基础配置
- vendor 的 include 与上游进一步分叉;该分叉已记录在 vendor 的 manifest 里,下次同步时重新应用
- 已知缺口不在本次范围内HMR 监听器只处理 chokidar 的 `change` 事件因此通过重命名替换文件的编辑方式BSD `sed -i``git checkout`)完全不会触发配置重载;应用配置项重载后也不会可见地重启运行中的 TUI未修改的代码树上即已如此
- 实时刷新失败会在内部 reject补偿成功时会保留或恢复上一份完好的树并广播一次类型化失败而不会成为未处理的 rejection
- 回滚失败可见,并可能使一个配置项不可用;事件和日志不会误称其已恢复
- 等待已声明依赖的 fiber 仍是有效的 pending 配置项:生命周期完成只表示当前工作均未失败,而不表示每项依赖都存在
- 确切配置 watcher 只为已注册路径增加文件系统资源,并随其所属 HMR fiber 一起释放
- vendor 中的 Loader、Include、HMR 与核心事件类型定义进一步偏离上游;全部分叉均维护在 vendor manifest元数据清单中。
## Testing
`packages/ui/app-boot/tests/config-reload.spec.ts` 用真实 Loader 树加载临时配置并固定以下行为:无效 YAML 编辑和空文件编辑都让 `refresh()` 正常 resolve 而不产生 rejection并保留之前的配置项配置随后的合法编辑正常生效overlay 配置树在重新读取时重新应用配置项补丁和插入的配置项;对 include 配置项自身 `patches` 的热更新立即生效、在下一次文件重读后依然保持、并在补丁移除后干净地回退。这些断言在未打补丁的 vendor include 上会失败
`packages/ui/app-boot/tests/config-reload.spec.ts` 启动真实的临时 Loader/Include 树,并覆盖对解析和形状错误的拒绝、先导入再 dispose、插件/配置恢复、多配置项回滚、祖先禁用、overlay 收敛、option 对象身份、失败的直接更新不持久化以及失败的程序化移动。`packages/ui/app-boot/tests/hmr-config.spec.ts` 覆盖现有和缺失的确切路径、添加/变更/移除、串行化合并、dispose 排空、非 `Error` 值的规范化、失败广播以及对发生 rejection 的观察者的收容。`packages/host/webserver/tests/webserver.spec.ts` 证明受服务门控的启动失败会让 Loader 组合以其 bind 诊断 reject`packages/typert/loader/tests/loader.spec.ts` 则通过真实 Loader 消费方演练可等待的程序化移除ACPAgent Client Protocol`pty-tools` 快照会防止并发组合改变同优先级提示词段的顺序

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-agent-runtime-context.md
2026-07-28-web-agent-runtime-context.md: 449c9d4ba2b144d02dee4b98ae80c86815aec5c1
2026-07-28-web-agent-runtime-context.zh.md: def1674be5f193739bfb214a24f34590ee075d5f

View File

@@ -0,0 +1,33 @@
# Agent Note: Web agents receive explicit runtime context
Status: implemented
English | [中文](2026-07-28-web-agent-runtime-context.zh.md)
## Problem
The shared CLI base configured an empty deployment persona, the Web overlay did not replace it, and the Web launcher added no source or interaction-surface section. A session header recorded its working directory for tools and persistence, but the model prompt did not state that directory or identify the DeepSeek Harness Web GUI. A request such as “change this page's theme” therefore made the agent search the selected project for an unspecified page, even when the user meant the GUI running the session.
## Decision
The shared Web/headless overlay (`apps/cli/config/web.cordis.yml`) supplies a concise coding-agent persona containing the resolved `{{model}}` and session `{{cwd}}`. `dsh web` additionally resolves the harness checkout from the launcher's module URL, installs the existing `harness:source` section, and adds an `app:web-surface` section before serving requests. The launcher registers that setup before mounting the config tree; its `systemPrompt` injection therefore installs both sections before later prompt consumers such as the agent loop can activate and emit a request header. The [source-checkout/workdir decision](2026-07-30-source-checkout-workdir-distinction.md) owns the source section's wording and its warning not to infer one path from the other.
The Web section treats unqualified references to “this page,” “this GUI,” or “this app” as references to the DeepSeek Harness Web GUI. It also states that the browser provides no implicit DOM, route, or screenshot context, so the model can identify the product without claiming visual state it did not receive. The assembled text is logged in `request/header`, preserving the model-visible/logged invariant.
## Verification
The focused startup-order test registers a later `systemPrompt` consumer and proves that it observes both launcher sections on its first activation. The keyless fresh-round-trip Web scenario boots the shipped base plus Web overlay, registers the same launcher context as `dsh web`, runs a real session through the HTTP/SSE application, and snapshots the first four system-prompt sections with source and working-directory paths normalized. The snapshot pins the harness identity, source checkout, Web orientation, and resolved coding-agent persona in request order.
## Alternatives considered
**Send URL, DOM, or screenshots with every prompt.** The observed failure needed stable product orientation, while the current root URL does not identify a selected component and no visual capture exists in the message contract. Adding dynamic page state would require a separate logged model-input design and is not implied by this fix.
**Require the session Workspace to be the harness checkout.** Workspace cwd is the user's task target and may legitimately be an empty project or another repository. Conflating it with the application's source location would break that boundary and leave installed or externally launched sessions ambiguous.
**Put Web wording in the global harness identity.** `dsh-system-prompt` serves TUI, ACP, SDK, and custom deployments that do not run in a browser. The composing Web app owns this surface fact.
**Change the existing source-location section for every CLI surface.** The source section is shared with TUI and states only the checkout fact. Keeping Web orientation separate preserves that reusable contract and avoids telling headless or terminal agents that they are in a browser.
## Consequences
Web requests gain a short stable prompt prefix and may invalidate provider prefix caches once when this change is deployed. Agents can distinguish the GUI source checkout from the selected Workspace and resolve ordinary references to the current app without a clarification round trip. References to a specific visual state remain bounded by the explicit no-DOM/no-route/no-screenshot statement and may still require a path, description, or attachment.

View File

@@ -0,0 +1,33 @@
# Agent Note: Web agent 获得显式运行时上下文
Status: implemented
[English](2026-07-28-web-agent-runtime-context.md) | 中文
## 问题
CLI 共享 base 配置了空的部署 personaWeb overlay 没有替换它,而 Web 启动器既未添加源码提示词段,也未添加交互界面提示词段。会话 header 会记录工作目录,供工具与持久化使用,但模型提示词既不说明该目录,也不标识 DeepSeek Harness Web GUI。因此当用户提出「修改这个页面的主题」之类的请求时即使用户指的是承载当前会话的 GUIagent 也只能在所选项目中搜索一个未明确说明的页面。
## 决策
`apps/cli/config/web.cordis.yml` 这份 Web无头共享 overlay 提供一段简洁的编码 agent persona其中包含解析后的 `{{model}}` 与会话 `{{cwd}}``dsh web` 还会根据启动器模块的 URL 解析 harness checkout安装现有的 `harness:source` 提示词段,并在对外提供请求服务前添加 `app:web-surface` 提示词段。启动器会在挂载配置树前注册这项设置;因此,它的 `systemPrompt` 注入会在 agent loop智能体循环等后续提示词消费方激活并发出 request header 之前安装这两个提示词段。源码提示词段的措辞,以及其中不得从一条路径推断另一条路径的警告,均由另行记录的[源码 checkout 与工作目录区分决策](2026-07-30-source-checkout-workdir-distinction.md)负责。
Web 提示词段把未限定的「这个页面」「这个 GUI」或「这个应用」解释为 DeepSeek Harness Web GUI。同时它会明确说明浏览器不会隐式提供 DOM、路由或截图上下文使模型能够识别产品但不会声称掌握未收到的视觉状态。组装后的文本会记录在 `request/header` 中,从而保持「模型可见内容必须有日志记录」这一不变量。
## 验证
聚焦启动顺序的测试会注册一个后续的 `systemPrompt` 消费方,并证明该消费方首次激活时就能观察到启动器的两个提示词段。无密钥的 Web fresh-round-trip 场景会启动已交付的 base 与 Web overlay注册与 `dsh web` 相同的启动器上下文,并通过 HTTPSSE 应用运行一个真实会话。测试会把源码路径和工作目录规范化,然后对系统提示词的前四个段落生成快照。该快照按请求顺序固定 harness 身份、源码 checkout、Web 界面定位,以及解析后的编码 agent persona。
## 考虑过的替代方案
**每次提示词都发送 URL、DOM 或截图。** 本次故障只需要稳定的产品定位;当前根 URL 无法标识所选组件,消息契约中也不存在视觉捕获内容。添加动态页面状态需要另行设计可记录的模型输入,不属于本次修复的隐含范围。
**要求会话 Workspace 必须是 harness checkout。** Workspace cwd 是用户任务的目标,可以合理地指向空项目或其他仓库。将其与应用源码位置混为一谈会破坏这一边界,并且仍无法消除已安装版本或外部启动会话中的歧义。
**把 Web 文案放入全局 harness 身份。** `dsh-system-prompt` 还服务于 TUI、ACP、SDK 和不在浏览器中运行的自定义部署。该界面事实应由组装 Web 应用负责。
**为所有 CLI 界面修改现有源码位置提示词段。** TUI 也复用源码位置提示词段,而该段只陈述 checkout 事实。单独保留 Web 界面定位可以维持这份可复用契约,避免错误地告诉无头或终端 agent 它们正处于浏览器中。
## 影响
Web 请求会增加一段较短且稳定的提示词前缀部署此变更时模型提供方的前缀缓存可能失效一次。agent 可以区分 GUI 源码 checkout 与所选 Workspace并且无需再经过一轮澄清即可解析对当前应用的一般指代。对特定视觉状态的指代仍受「无 DOM无路由无截图」这一显式边界约束必要时仍需用户提供路径、描述或附件。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md
2026-07-28-web-gui-feedback-loop.md: 039d2aebeeef903d10838a46b48e5172f0195126
2026-07-28-web-gui-feedback-loop.zh.md: 34b6b26d4ce7c7e194e641536fffc503c013188b

View File

@@ -0,0 +1,39 @@
# Agent Note: Web GUI changes close the loop on the existing URL
Status: implemented
English | [中文](2026-07-28-web-gui-feedback-loop.zh.md)
## Problem
The Web agent could identify neither the GUI hosting its session nor the URL the user was viewing. The [runtime-context decision](2026-07-28-web-agent-runtime-context.md) supplies the first fact, but a GUI edit still had no executable acceptance target: source edits, artifact builds, a listening process, and the user's existing page were unrelated observations. Repository affordances made a wrong substitute look valid because `apps/web/package.json` exposed `vite` as its `dev` script and bare Vite returned HTTP 200 even though it could not inject `window.__DSH_BOOT__`.
The [incident post-mortem](../../../../docs/postmortem/0003-web-agent-gui-feedback-loop.md) owns the event-log timeline and why the original checks accepted the wrong page, process, and port.
## Decision
`dsh web` publishes one canonical loopback URL and its actual runtime mode as both model-visible orientation and managed shell facts. The `app:web-surface` prompt section says that unqualified references identify this GUI and names the URL; `DSH_WEB_URL` and `DSH_WEB_MODE=production|development` carry the same facts into every foreground or managed background bash call. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address.
The mode-specific prompt makes the agent, rather than the user, own the hidden startup contract. Production mode defines acceptance as rebuilding the affected artifacts and refreshing the existing URL. Development mode states that `dsh web --dev` activates only the HMR receiver: automatic client-plugin reload additionally requires a same-checkout `pnpm run dev:web` watcher, which the agent verifies before promising no-refresh updates. Shell and other plain-package changes still require rebuild plus refresh. An agent in production mode explains both commands when a user requests no-refresh updates; it does not launch a replacement GUI unless asked.
The `apps/web` development script and Vite configuration reject serve mode before opening a port. Their diagnostics identify `apps/web` as a build-only shell, explain that only `dsh web` injects `window.__DSH_BOOT__`, and name the production and HMR entry paths. Vite build mode remains unchanged.
No server restart or replacement is required merely because static artifacts changed. The host reads `index.html` and static assets on each request, while client bundles are also served from their current files with `no-cache`; a refresh of the existing URL is therefore the acceptance path after the relevant shell and plugin bundles are rebuilt. Starting a separate server proves only that a separate server works. If the user explicitly requests another long-running server, the existing managed background-task contract owns its lifecycle and completion notices; shell `&` is not an alternative lifecycle.
## Verification
The keyless fresh-round-trip browser scenario boots the shipped production Web composition, drives a real replayed session, snapshots the URL/mode-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` and `$DSH_WEB_MODE` match the actual bound runtime. The real CLI smoke launches `dsh web --dev` and captures the provider request, pinning the complete two-command development contract. The `dev:web` watcher test rebuilds an isolated client bundle after a source change; the browser HMR scenario launches `dsh web --dev`, changes an initial production-roster bundle, and observes the new DOM under the same page identity. A real Vite subprocess test requires serve mode to exit naturally with the full-host correction and instruments `Server.listen()` to prove it was never called. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, DOM identity, and HTTP bytes rather than an agent's success statement.
## Alternatives considered
**Extend only the system prompt.** Rejected because it would leave the target unavailable to tools, preserve the misleading bare-Vite path, and fail to prove how an existing process observes rebuilt artifacts.
**Remove the `apps/web` development script without guarding Vite.** Rejected because `npx vite`, the exact incident command, bypasses package scripts. Serve mode itself must fail.
**Automatically restart or replace the current Web process after every edit.** Rejected because the static server already reads current artifacts per request, a restart would interrupt the session that requested the edit, and plugin HMR has a separate explicit `dsh web --dev` composition.
**Send DOM, route, or screenshots with each request.** Deferred to a separate logged-input design. Stable URL identity closes this feedback loop without claiming browser state the host does not receive.
## Consequences
Web prompts gain a dynamic URL-and-mode paragraph, so provider prefix reuse now varies by bound port and mode. Bash processes gain two non-secret managed environment variables. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the agent can teach the user the exact update behavior of the process actually serving their session, and the unsupported startup path fails before a white screen. The URL/mode contract guides the agent away from replacement ports; it does not prohibit arbitrary shell commands from starting one.

View File

@@ -0,0 +1,39 @@
# Agent Note: Web GUI 改动在现有 URL 上闭环
Status: implemented
[English](2026-07-28-web-gui-feedback-loop.md) | 中文
## 问题
Web agent智能体既无法识别承载当前会话的 GUI也不知道用户正在查看哪个 URL。[运行时上下文决策](2026-07-28-web-agent-runtime-context.md)提供前一项事实,但 GUI 编辑仍然没有可执行的验收目标:源码编辑、产物构建、监听中的进程与用户已打开的页面只是互不关联的观察结果。仓库提供的入口让错误的替代方案显得合理,因为 `apps/web/package.json``vite` 暴露为 `dev` 脚本,而裸 Vite 即使无法注入 `window.__DSH_BOOT__`,仍会返回 HTTP 200。
[事故复盘](../../../../docs/postmortem/0003-web-agent-gui-feedback-loop.md)集中记录事件日志时间线,并解释原有检查为何会接受错误的页面、进程和端口。
## 决策
`dsh web` 发布一个规范的回环 URL 及其实际运行时模式,同时将二者作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 提示词段说明:未加限定的指代指向此 GUI并给出 URL`DSH_WEB_URL``DSH_WEB_MODE=production|development` 会把同样的事实传入每次前台或受管后台 bash 调用。该段保留「不会隐式获得 DOM、路由或截图」这一边界也不声称局域网别名等于浏览器中的实际地址。
按模式区分的提示词让 agent 而非用户负责隐藏的启动契约。生产模式将验收定义为重新构建受影响的产物并刷新现有 URL。开发模式说明`dsh web --dev` 只会启用 HMR热模块替换接收端客户端插件要自动重新加载还需要在同一检出中运行 `pnpm run dev:web` 监听进程agent 会在承诺无需刷新即可更新前验证这一点。外壳和其他普通包的变更仍然需要重新构建并刷新。生产模式下的 agent 会在用户要求无需刷新即可更新时说明这两个命令;除非用户要求,否则不会启动替代 GUI。
`apps/web` 开发脚本和 Vite 配置都会在打开端口前拒绝服务模式。诊断信息会指出 `apps/web` 只是一个仅供构建的外壳,说明只有 `dsh web` 才会注入 `window.__DSH_BOOT__`,并给出生产入口与 HMR 入口路径。Vite 构建模式保持不变。
静态产物发生变化时,不需要仅为此重启或替换服务器。宿主会在每次请求时读取 `index.html` 和静态资源,客户端 bundle 也会从当前文件提供,并设置 `no-cache`;因此,重新构建相关外壳与插件 bundle 后,刷新现有 URL 就是验收路径。启动另一个服务器只能证明另一个服务器可用。如果用户明确要求再启动一个长期运行的服务器则现有受管后台任务契约负责其生命周期和完成通知shell `&` 不能替代这套生命周期机制。
## 验证
无密钥的 fresh-round-trip 浏览器场景会启动已交付的生产 Web 组合,驱动真实的回放会话,对包含 URL 和模式的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL``$DSH_WEB_MODE` 与实际绑定的运行时一致。真实 CLI 冒烟测试会启动 `dsh web --dev` 并捕获模型提供方请求,从而固定完整的双命令开发契约。`dev:web` watcher 测试会在源码发生变化后重新构建隔离的客户端 bundle浏览器 HMR 场景会启动 `dsh web --dev`,修改生产初始 roster 中的 bundle并在页面 identity 不变的情况下观察新 DOM。真实 Vite 子进程测试要求服务模式在给出改用完整宿主的纠正信息后自然退出,并通过插桩 `Server.listen()` 证明它从未被调用。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出、DOM identity 和 HTTP 字节,而不是 agent 的成功声明。
## 考虑过的替代方案
**仅扩展系统提示词。** 不予采纳,因为这样会让工具仍然无法获得目标,保留具有误导性的裸 Vite 路径,并且无法证明现有进程如何观察重新构建的产物。
**删除 `apps/web` 开发脚本,但不为 Vite 添加防护。** 不予采纳,因为事故中实际使用的命令 `npx vite` 会绕过包脚本。服务模式本身必须失败。
**每次编辑后自动重启或替换当前 Web 进程。** 不予采纳,因为静态服务器本就会在每次请求时读取当前产物,重启还会中断发起编辑请求的会话,而插件 HMR 已有独立且显式的 `dsh web --dev` 组合。
**每次请求都发送 DOM、路由或截图。** 推迟到另行设计的已记录输入机制。稳定的 URL 身份足以闭合本次反馈循环,同时不会声称宿主掌握其未接收的浏览器状态。
## 影响
Web 提示词会增加一个动态 URL 和模式段落因此模型提供方的前缀复用会随绑定端口和模式变化。Bash 进程会增加两个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱开发者应改用完整宿主或构建模式。作为交换GUI 工作有了一个可由机制观察的唯一目标agent 可以向用户说明实际承载其会话的进程究竟如何更新不受支持的启动路径也会在出现白屏前失败。URL模式契约会引导 agent 避免使用替代端口,但不会禁止任意 shell 命令启动替代服务。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md
2026-07-30-composer-context-stack-order.md: 9c269bcaf7fa360b6a5d0e16fd8cda48aa285d66
2026-07-30-composer-context-stack-order.zh.md: 47288141ea4591b29adde0f85e810fc797740488
2026-07-30-composer-context-stack-order.md: 150225ce27fa8459703170622cef754b7694e2fc
2026-07-30-composer-context-stack-order.zh.md: f13ffc3c1136707a86ba5636a5b69304a132df01

View File

@@ -10,7 +10,7 @@ Goal, Todo, and Queue contribute independently to the same `conversation.input.d
## Decision
The composer context stack has one canonical ascending order: Goal at `0`, Todo at `10`, and Queue at `20`, followed by the composer bar outside the list. The gaps leave room for future entries to declare their intended position without relying on plugin activation order.
The [Todo-first alignment decision](2026-08-02-todo-first-composer-context-order.md) owns the current ascending order. This note retains the stack contract around that order: numeric gaps leave room for future entries to declare their intended position without relying on plugin activation order, and the composer bar follows the list.
`ConversationRoot` owns the 6px space between independent context cards. Goal is a standalone 752×36px card and collapsed Todo is a standalone 752×44px card. Queue is the terminal dock entry: its 776px wrapper contains the same 752px panel column and subtracts the shared gap plus a named 5px layout overlap, so the later composer card paints over only the queue edge. Empty entries render null and consume no gap.
@@ -18,7 +18,7 @@ The order and overlap are separate contracts. Registration order establishes sem
## Verification
Registration tests pin all three order values. Browser screenshots cover the full Goal/Todo/Queue matrix, Goal+Todo without Queue, and Queue alone; together they exercise every adjacency: GoalTodo, TodoQueue, and QueueComposer.
Registration tests pin all three order values. The keyless Queue browser scenario renders Todo, Goal, and Queue together, pins their accessibility order, and checks their visible card edges; focused Goal and Queue scenarios cover their independent states.
## Alternatives considered
@@ -30,4 +30,4 @@ Registration tests pin all three order values. Browser screenshots cover the ful
## Consequences
The visual hierarchy is stable for every presence combination, and Queue is the only context surface joined to the composer. New input-dock plugins must choose an order relative to Goal `0`, Todo `10`, and Queue `20`; an entry after Queue also requires an explicit decision about which surface owns the composer boundary.
The visual hierarchy is stable for every presence combination, and Queue is the only context surface joined to the composer. New input-dock plugins must choose an order relative to Todo `0`, Goal `10`, and Queue `20`; an entry after Queue also requires an explicit decision about which surface owns the composer boundary.

View File

@@ -10,7 +10,7 @@ Goal、Todo 与 Queue 独立注册到同一个 `conversation.input.dock` 列表
## 决策
composer 上下文堆栈采用唯一规范的升序排列Goal 为 `0`Todo 为 `10`Queue 为 `20`,随后是位于列表外的 composer bar。顺序值之间的空档使未来条目可以声明预期位置,不必依赖插件激活顺序。
[Todo 优先的对齐决策](2026-08-02-todo-first-composer-context-order.md)规定当前的升序排列。本记录保留围绕该顺序的堆栈契约:数值间隔使未来条目可以声明预期位置,不必依赖插件激活顺序composer bar 位于列表之后
`ConversationRoot` 负责独立上下文卡片之间的 6px 间距。Goal 是一张独立的 752×36px 卡片,折叠后的 Todo 是一张独立的 752×44px 卡片。Queue 是末端 dock 条目:其 776px 包装层包含相同的 752px 面板列,并减去共享间距与具名的 5px 布局重叠量,因此后渲染的 composer 卡片只覆盖 Queue 边缘。空条目渲染为 null不占用间距。
@@ -18,7 +18,7 @@ composer 上下文堆栈采用唯一规范的升序排列Goal 为 `0`Todo
## 验证
注册测试固定了三个顺序值。浏览器截图覆盖完整的 GoalTodoQueue 组合矩阵、没有 Queue 的 Goal+Todo以及仅有 Queue 的情况这些场景共同覆盖全部相邻关系GoalTodo、TodoQueue 与 QueueComposer
注册测试固定了三个顺序值。无密钥 Queue 浏览器场景同时渲染 Todo、Goal 和 Queue固定它们的无障碍顺序并检查其可见卡片边缘聚焦的 Goal 与 Queue 场景覆盖各自的独立状态
## 考虑过的替代方案
@@ -30,4 +30,4 @@ composer 上下文堆栈采用唯一规范的升序排列Goal 为 `0`Todo
## 后果
所有存在组合下的视觉层级都保持稳定Queue 是唯一与 composer 相接的上下文界面。新的 input-dock 插件必须相对于 Goal `0`Todo `10` 与 Queue `20` 选择顺序;若条目位于 Queue 之后,还必须明确决定由哪个界面负责 composer 边界。
所有存在组合下的视觉层级都保持稳定Queue 是唯一与 composer 相接的上下文界面。新的 input-dock 插件必须相对于 Todo `0`Goal `10` 与 Queue `20` 选择顺序;若条目位于 Queue 之后,还必须明确决定由哪个界面负责 composer 边界。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-source-checkout-workdir-distinction.md
2026-07-30-source-checkout-workdir-distinction.md: ba6d9dd12b55a54d4ae8d2e91ad83ac3c1dc47fd
2026-07-30-source-checkout-workdir-distinction.zh.md: ffc2ac7baa2b1bb8ce54607638c35869fc338825

View File

@@ -0,0 +1,33 @@
# Agent Note: Source checkout paths do not define working directories
Status: implemented
English | [中文](2026-07-30-source-checkout-workdir-distinction.zh.md)
## Problem
The `harness:source` prompt section follows the [source-location decision](../../archived/feature/2026-07-21-dsh-system-prompt-source-path.md), but its original wording called the checkout “your own source code” without distinguishing that path from the session workspace. In a normal TUI configuration that does not state `{{cwd}}` in its persona, this may be the only fixed absolute path near the start of the system prompt. DeepSeek V4 could therefore answer “what's the workdir?” with the harness checkout instead of determining the session's current working directory.
A blanket statement that the checkout is not the working directory would also be false. `dsh meta` intentionally makes the source checkout both values.
## Decision
The section identifies the path as the “DeepSeek Harness implementation checkout.” It says that the checkout location and current working directory are separate values that may differ, forbids inferring the working directory from the checkout path, directs the model to use `pwd`, and limits the checkout's purpose to inspecting or extending DSH itself.
The path derivation, global `harness:source` ownership, and `-99` ordering remain unchanged. Describing the values as conceptually separate rather than always unequal keeps the instruction accurate in both ordinary project sessions and `dsh meta`.
## Verification
The `dsh-app-boot` unit test pins the exact text and its ordering. The CLI keyless PTY smoke inspects the assembled request header. The TUI `source-checkout-workdir` snapshot mounts the section with `/opt/dsh-source`, asks “what's the workdir?” through a recorded DeepSeek V4 turn, and requires the replayed transcript to run `pwd` and report the generated workspace rather than the checkout.
## Alternatives considered
**Say that the checkout is never the working directory.** Rejected because `dsh meta` deliberately makes them the same path.
**Put the current working directory in the global source section.** Rejected because the source section is launcher-global while the working directory belongs to each session; combining them would duplicate the loop's `cwd` ownership and make a stable source fact vary per agent.
**Remove the source path from the prompt.** Rejected because self-referential DSH tools still need a reliable checkout location when the launcher starts from an unrelated project.
## Consequences
The prompt is longer and a direct working-directory question may spend one inexpensive `pwd` tool call. In exchange, the model no longer treats the harness implementation path as an implicit task workspace, while meta mode remains truthful when both values coincide.

View File

@@ -0,0 +1,33 @@
# Agent Note: 源码 checkout 路径不定义工作目录
Status: implemented
[English](2026-07-30-source-checkout-workdir-distinction.md) | 中文
## Problem
`harness:source` 提示词段遵循[源码位置决策](../../archived/feature/2026-07-21-dsh-system-prompt-source-path.md),但原有措辞把 checkout 称为“你自己的源代码”,却没有区分该路径与会话 workspace。在 persona 不声明 `{{cwd}}` 的普通 TUI 配置中这可能是系统提示词开头附近唯一固定的绝对路径。因此DeepSeek V4 可能会直接用 harness checkout 回答“what's the workdir?”,而不是确定会话的当前工作目录。
直接断言 checkout 不是工作目录同样不准确。`dsh meta` 会有意让源码 checkout 同时充当这两个值。
## Decision
该提示词段将路径标识为“DeepSeek Harness implementation checkout”。它说明 checkout 位置与当前工作目录是两个可能不同的值,禁止从 checkout 路径推断工作目录,指示模型使用 `pwd`,并限定该 checkout 只用于检查或扩展 DSH 自身。
路径推导方式、全局 `harness:source` 所有权和 `-99` 顺序均保持不变。将两者描述为概念上独立、而不是始终不相等,使这条指令在普通项目会话和 `dsh meta` 中都准确。
## Verification
`dsh-app-boot` 单元测试固定了完整文本及其顺序。CLI 无密钥 PTY 冒烟测试检查组装后的请求 header。TUI 的 `source-checkout-workdir` 快照把该提示词段挂载为 `/opt/dsh-source`,通过录制的 DeepSeek V4 turn 提问“what's the workdir?”,并要求回放 transcript 运行 `pwd`,报告生成的 workspace 而不是 checkout。
## Alternatives considered
**声明 checkout 永远不是工作目录。**拒绝:`dsh meta` 会有意让它们指向同一路径。
**把当前工作目录写入全局源码提示词段。**拒绝:源码提示词段由 launcher 全局持有,而工作目录属于各个会话;将两者合并会重复 loop 对 `cwd` 的所有权,还会让稳定的源码事实随 agent 变化。
**从提示词中删除源码路径。**拒绝launcher 从无关项目启动时,自引用 DSH 工具仍需要可靠的 checkout 位置。
## Consequences
提示词会变长,直接询问工作目录时可能多花一次廉价的 `pwd` 工具调用。作为交换,模型不再把 harness 实现路径当作隐含的任务 workspace当 meta 模式使两个值重合时,提示词仍然准确。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.md
2026-07-31-fork-anchor-floors-to-event-seq.md: 2cc8d66942442aca69c930be880cc41d56bced13
2026-07-31-fork-anchor-floors-to-event-seq.zh.md: 9f443f3d5165c01c6585d33bd6a37e90e9b3bf95

View File

@@ -0,0 +1,35 @@
# Agent Note: Fork anchor floors to an event seq
Status: implemented
English | [中文](2026-07-31-fork-anchor-floors-to-event-seq.zh.md)
## Problem
The fork button on a stopped assistant message did nothing at all — no child session, no error, no visible reaction.
The frozen node behind that message is not a log event. Both the live projection and the history replay mint it with a flow-ordering seq of `turnEnd.seq - 0.9`, placing it strictly after every event of the aborted turn and before the next one, and the chat view hands that node seq to the fork entry point unchanged. `session.fork` accepts a non-negative integer on the wire, so a fractional anchor is rejected as invalid-params before the request reaches the host, and the chat entry's fork call swallows failures. Nothing distinguished the rejection from an inert button.
The host's cut rule was never the obstacle. An aborted turn ends with a logged `turn/end` carrying reason `aborted`, so it is a completed prefix like any other and the anchor simply never arrived.
## Decision
`SessionsService.fork` floors `atSeq` before the RPC. The fractional-seq convention belongs to `dsh-client-runtime`, which mints it in both the live and replay projections, so the same package converts it back to a real event seq at the wire boundary instead of every UI caller remembering to. Integer anchors are unaffected.
Flooring lands inside the anchor's own turn rather than clipping backward: every turn opens with `turn/start`, so `turnEnd.seq - 1` cannot itself be an earlier turn's `turn/end`. The host's first-`turn/end`-at-or-after rule then closes on the turn the reader clicked, matching the whole-turn semantics the message-level fork button already promised for completed turns.
The apiproxy fork suite pins the host half of the contract: a floored anchor inside an aborted turn cuts through that turn and seeds the child with it.
## Alternatives considered
**Accept fractional `atSeq` on the wire.** Rejected because the host contract is an event seq, not a position on a continuum; the fractional form is one client's rendering convention, and admitting it would leave `atSeq` alone among the seq-carrying payloads in taking non-integers.
**Hide the fork button on interrupted messages.** Rejected because forking a turn the reader deliberately stopped is one of the strongest reasons to fork at all, and the capability worked host-side the whole time.
**Floor in the chat entry's `forkAt` adapter.** Rejected because `ui-conversation` consumes the fractional convention without owning it; any second fork entry point would have to rediscover the same conversion.
## Consequences
Forking from a stopped turn produces a child seeded through that turn's `turn/end`. The frozen partial text is reconstructed from chunk events and was never an `assistant/message`, so it stays out of the child's model transcript exactly as it stays out of the source's on resume — the child resumes from the same context the source would.
Fork failures stay silent in the chat entry. This bug survived because that call site discards its rejection; surfacing fork errors in the UI is a separate change.

View File

@@ -0,0 +1,35 @@
# Agent Note: fork 锚点向下取整到事件 seq
Status: implemented
[English](2026-07-31-fork-anchor-floors-to-event-seq.md) | 中文
## 问题
在已停止的助手消息上点 fork 毫无反应——没有子会话,没有报错,也没有任何可见变化。
这条消息背后的冻结节点并不是日志事件。实时投影和历史回放都用 `turnEnd.seq - 0.9` 这个排序坐标来生成它,让它严格落在被中断轮次的所有事件之后、下一轮之前,而 chat 视图原样把这个节点 seq 交给 fork 入口。`session.fork` 在 wire 上只接受非负整数,因此分数锚点在抵达 host 之前就被判为 invalid-params而 chat 入口的 fork 调用又吞掉了失败。于是被拒绝和按钮失灵在表现上毫无区别。
host 的切分规则从来不是障碍。被中止的轮次会记录一条 reason 为 `aborted``turn/end`,它和其他轮次一样是可切分的完整前缀——只是锚点根本没送到。
## 决策
`SessionsService.fork` 在发起 RPC 前对 `atSeq` 向下取整。分数 seq 这个约定属于 `dsh-client-runtime`,实时投影和回放投影都由它生成,因此也由同一个包在跨出 wire 边界时把它换回真实事件 seq而不是要求每个 UI 调用方各自记得转换。整数锚点不受影响。
向下取整落在锚点自身所在的轮次内,不会回退:每一轮都以 `turn/start` 开头,所以 `turnEnd.seq - 1` 不可能是上一轮的 `turn/end`。host 随后按「首个位于锚点或其之后的 `turn/end`」收口,命中的正是读者点击的那一轮,与消息级 fork 按钮在已完成轮次上一贯承诺的整轮语义一致。
apiproxy 的 fork 用例固定了 host 这一侧的契约:落在被中止轮次内的取整锚点会切穿该轮,并把它种进子会话。
## 备选方案
**让 wire 接受分数 `atSeq`。** 否决host 契约要的是事件 seq而不是连续坐标上的某个位置分数形式只是某一个客户端的渲染约定一旦放行`atSeq` 会成为所有携带 seq 的载荷中唯一容忍非整数的字段。
**在已中断的消息上隐藏 fork 按钮。** 否决:从读者主动叫停的那一轮分叉,恰恰是最需要 fork 的场景之一,而 host 侧这个能力一直是好的。
**在 chat 入口的 `forkAt` 适配器里取整。** 否决:`ui-conversation` 只是分数约定的消费方,并不拥有它;将来任何第二个 fork 入口都得把同样的转换重新发现一遍。
## 影响
从已停止的轮次 fork 会得到一个种子切到该轮 `turn/end` 的子会话。被冻结的残缺文本是从 chunk 事件重建出来的,从未成为 `assistant/message`,因此它不会进入子会话的模型上下文——正如源会话恢复时它也不会进入一样,子会话拿到的上下文与源会话一致。
fork 失败在 chat 入口仍然是静默的。这个 bug 能存活至今,正是因为该调用点丢弃了自己的 rejection把 fork 错误呈现到 UI 上是另一件事。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md
2026-07-31-web-stop-preserves-queue.md: 943e95d6951a28929c4f8ce4d0b6e17224b08ede
2026-07-31-web-stop-preserves-queue.zh.md: bbadd8adf8fd5bb0604ef87d322e48ce4c2ed759
2026-07-31-web-stop-preserves-queue.md: b8bba45009c126495a8b03ee36d6eb2cccac9a93
2026-07-31-web-stop-preserves-queue.zh.md: 0e1fffae8819bea81b91fae10c76dbadc78474fe

View File

@@ -12,7 +12,7 @@ The browser cannot repair that loss by resending visible rows. It does not own t
## Decision
`session.cancel` is the Web Host API's active-turn stop. It calls `agent.cancel({ kind: 'user' }, { keepInbox: true })`, preserving pending inbox work while cooperatively aborting the current turn. The underlying option preserves queued and steering entries; the Web Queue projection continues to expose only queued entries.
`session.cancel` is the Web Host API's active-turn stop for ordinary sessions. It rejects session-backed subagents with `agent-busy`; otherwise it calls `agent.cancel({ kind: 'user' }, { keepInbox: true })`, preserving pending inbox work while cooperatively aborting the current turn. The underlying option preserves queued and steering entries; the Web Queue projection continues to expose only queued entries.
The AgentLoop starts no concurrent replacement turn. It closes and flushes the interrupted turn, reaches cancellation quiescence, and then claims the next waking queued occurrence through its existing FIFO driver. That claim emits `agent/inbox/dequeue`, so the Host's authoritative `session/queue` snapshot retires the claimed row and leaves the remaining tail visible. The browser neither resends nor promotes any row. Work that ignores cancellation delays this handoff until it settles.

View File

@@ -12,7 +12,7 @@ Web 停止按钮调用 `session.cancel`,后者映射到广义 `agent.cancel({
## 决策
`session.cancel` 是 Web Host API 的活动轮次停止操作。它调用 `agent.cancel({ kind: 'user' }, { keepInbox: true })`,在协作式中止当前轮次的同时保留待处理 inbox 工作。底层选项会保留 queued 和 steering 入队项Web Queue 投影继续只暴露 queued 入队项。
`session.cancel` 是 Web Host API 面向普通会话的活动轮次停止操作。它会以 `agent-busy` 拒绝由会话支撑的 subagent否则会调用 `agent.cancel({ kind: 'user' }, { keepInbox: true })`,在协作式中止当前轮次的同时保留待处理 inbox 工作。底层选项会保留 queued 和 steering 入队项Web Queue 投影继续只暴露 queued 入队项。
AgentLoop 不会启动并发的替代轮次。它会关闭并 flush 被中断的轮次,达到取消的完全停稳,然后通过现有 FIFO 驱动器认领下一个可唤醒的 queued 入队项。该认领会发出 `agent/inbox/dequeue`,因此 Host 的权威 `session/queue` 快照会退役已认领行,并使剩余队尾保持可见。浏览器既不重发,也不提升任何行。忽略取消的工作会延迟这一交接,直到该工作结算。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md
2026-08-02-goal-round-wrapup-message.md: c6bc3d5912b0789efde55880c2be892e98e34a5b
2026-08-02-goal-round-wrapup-message.zh.md: 0a504b4dcc61feb932775b3d9ffd8424f3b0597d

View File

@@ -0,0 +1,31 @@
# Agent Note: Goal-round wrap-up message
Status: implemented
English | [中文](2026-08-02-goal-round-wrapup-message.zh.md)
## Problem
An autonomous goal round that reported `update_goal` `complete` or `blocked` concluded the physical turn at the tool result, so the model never spoke after the call. Sessions ended on a bare `update_goal` card, and internal testers read that as the agent stopping mid-sentence: the model's pre-call text routinely announces a report ("goal achieved, marking complete:") that never arrives, because the standard tool-use expectation is one more assistant message after a tool result and neither the goal-round prompt nor the tool description said the call was terminal. The hard stop came from the [goal-tool decision](../feature/2026-07-19-model-facing-goal-tools.md), whose turn-stop clause this note supersedes.
## Decision
A goal-round `complete` or `blocked` success no longer calls `concludeTurn()`. Instead the tool defers one wrap-up context onto its own result: a `{ kind: 'plugin', plugin: 'tool-goal' }`-sourced user message carrying a `<goal_complete>`/`<goal_blocked>` instruction to write a grounded closing message to the user and call no more tools. The turn then ends through the agent loop's ordinary no-tool-calls stop, so no new loop primitive exists and steering semantics are untouched. Direct-human mutations remain uninstructed exactly as before. The cost is one additional model request per goal lifecycle, not per round.
The instruction wording was selected by A/B sampling on `deepseek-v4-pro` with a reconstructed goal-round transcript: a structured instruction (outcome, verification, artifacts, next steps) consistently beat a minimal "summarize" one on completeness; adding a session-grounding clause shifted unsupported detail from asserted fact to hedged suggestion; and the no-instruction control produced high-variance closings, including confidently fabricated file-level detail.
Scripting the keyless proof required one snapshot-harness addition: `dsh-llm-replay` resolves `{{fromRequest:<regex>}}` placeholders in scripted entries against the live request, because a static sidecar cannot know the randomly minted goal id the model must echo into `update_goal`.
## Verification
`tool-goal` package tests pin the injected context (source, tag, objective, no-more-tools clause) and the absent `concludesTurn` for both terminal actions, plus the uninstructed direct-human pause and complete paths, at 100% file coverage. `llm-replay` unit tests pin the placeholder contract: last-match-wins capture, whole-match fallback, and loud failures for unmatched, invalid, and unterminated patterns. The new keyless ACP snapshot `goal-wrapup` drives the shipped application through create → round one → autonomous complete and asserts the plugin-sourced wrap-up injection, the same-turn closing assistant message, and the `completed` turn end in both the durable session log and the ACP stdout stream.
## Alternatives considered
- **Surface the completion text on the `update_goal` UI card** — rejected: `complete` carries no free text today, and adding a `summary` argument would route a user-facing report through tool arguments while still cutting off the model's natural post-result message.
- **Keep `concludeTurn()` and add a "one more text-only step" loop primitive** — rejected: new `agent-loop` machinery for behavior the ordinary stop already provides once nothing concludes the turn.
- **Instruct inside the tool result content** — rejected: the goal tools' canonical output is compact JSON consumed programmatically; a prose instruction block inside it would mix the model-facing contract with the tool's replayable value.
## Consequences
Every autonomous goal ends with a user-facing closing message instead of a bare tool card, at the cost of one model request per goal lifecycle. `concludeTurn()` keeps its loop semantics but loses its only first-party caller outside subagent structured output. Snapshot scenarios can now script values that only exist at run time via `{{fromRequest:...}}`, which unblocks keyless coverage of any echo-an-id tool flow, goal or otherwise.

View File

@@ -0,0 +1,31 @@
# Agent NoteGoal Round 收尾消息
Status: implemented
[English](2026-08-02-goal-round-wrapup-message.md) | 中文
## 问题
自主 Goal Round 报告 `update_goal` `complete``blocked` 时,物理轮次在工具结果处直接终结,模型在调用之后再无发言机会。会话终止在一张裸的 `update_goal` 卡片上,内测同学的观感是 agent 话说到一半戛然而止:模型调用前的文本通常预告了一份汇报(“目标达成,标记完成:”)却永远没有下文,因为标准 tool-use 预期是工具结果之后还有一条 assistant 消息,而 Goal Round 提示词与工具描述都没有说明这次调用是终点。硬停止来自 [goal 工具决策](../feature/2026-07-19-model-facing-goal-tools.md),本 note 取代其中的轮次停止条款。
## 决策
Goal Round 的 `complete``blocked` 成功不再调用 `concludeTurn()`。工具改为在自己的结果上附带一条收尾上下文:以 `{ kind: 'plugin', plugin: 'tool-goal' }` 为 source 的 user 消息,携带 `<goal_complete>`/`<goal_blocked>` 指令,要求模型向用户写出有依据的收尾消息且不再调用工具。之后轮次经由 agent loop 常规的无工具调用停止路径结束,因此不存在新的 loop 原语steering 语义不受影响。人类直接变更保持原样、不注入指令。代价是每个 goal 生命周期一次额外模型请求,而非每轮一次。
指令措辞通过在 `deepseek-v4-pro` 上用重构的 Goal Round 转录做 A/B 采样选定:结构化指令(结果、验证、产物、后续)在完整度上稳定优于极简“总结一下”;补充“以会话内证据为准”的 grounding 条款让无依据细节从断言事实退为带保留的建议;而无指令对照组的收尾方差很大,包括言之凿凿的文件级细节编造。
为让 keyless 证明可脚本化,快照设施补了一项能力:`dsh-llm-replay` 会针对实时请求解析脚本条目中的 `{{fromRequest:<regex>}}` 占位符,因为静态伴随文件不可能预知模型必须回填进 `update_goal` 的随机生成 goal id。
## 验证
`tool-goal` 包测试钉住两个终态 action 注入的上下文source、标签、objective、禁止再调工具条款与不存在的 `concludesTurn`,以及人类直接 pause 与 complete 的不注入路径,文件覆盖率 100%。`llm-replay` 单元测试钉住占位符契约:最后一次匹配取胜的捕获、无捕获组时整体匹配回退,以及未匹配、非法、未闭合模式的明确报错。新增 keyless ACP 快照 `goal-wrapup` 驱动成品应用走完 create → 第一轮 → 自主 complete并在持久会话日志与 ACP stdout 流中同时断言 plugin 来源的收尾注入、同轮内的收尾 assistant 消息与 `completed` 轮次结束。
## 曾考虑的替代方案
- **在 `update_goal` 的 UI 卡片上展示完成文本** — 拒绝:`complete` 如今不携带任何自由文本;新增 `summary` 参数会让面向用户的汇报走工具参数通道,而且依然砍掉了模型在结果之后的自然发言。
- **保留 `concludeTurn()` 并新增“再多一步纯文本”的 loop 原语** — 拒绝:为常规停止路径已经能提供的行为(只要没有结果终结轮次)增加新的 `agent-loop` 机制。
- **把指令写进工具结果内容** — 拒绝goal 工具的规范输出是被程序化消费的紧凑 JSON在其中混入散文指令会把模型侧契约和工具的可回放值搅在一起。
## Consequences
每个自主 goal 都以一条面向用户的收尾消息结束,而非一张裸工具卡片,代价是每个 goal 生命周期一次模型请求。`concludeTurn()` 保留其 loop 语义,但在 subagent 结构化输出之外失去了唯一的一方调用者。快照场景现在可以通过 `{{fromRequest:...}}` 脚本化只在运行时才存在的值,为任何“回显 id”类工具流程不限于 goal解锁 keyless 覆盖。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.md
2026-08-02-todo-first-composer-context-order.md: 26962426c763c6911fc61acab09cdb96725922b1
2026-08-02-todo-first-composer-context-order.zh.md: a81401ffeb8df5855b584a5727e24067d9fb1e70

View File

@@ -0,0 +1,33 @@
# Agent Note: Todo-first composer context order
Status: implemented
English | [中文](2026-08-02-todo-first-composer-context-order.zh.md)
## Problem
The composer context stack rendered Goal before Todo even though the Harness design orders the current task plan before its ongoing goal and pending Queue. Todo also used the Queue wrapper's 776px width as its visible card width, while Goal and the Queue panel rendered on the shared 752px card column. The result inverted the intended information hierarchy and left Todo wider than both adjacent panels.
## Decision
The `conversation.input.dock` list uses one ascending product order: Todo at `0`, Goal at `10`, and Queue at `20`, followed by the composer bar outside the list. Registration order remains the semantic source of truth; the renderer does not hardcode known component ids or repair their order with CSS.
Todo, Goal, and the visible Queue panel share the 752px card column inside the 800px composer cap. Queue retains a 776px wrapper with 12px transparent inset on each side because that wrapper owns the composer overlap. Todo is a standalone card rather than a wrapper, so its responsive width and maximum width subtract both inset layers directly. Goal uses the same responsive column and caps its inner bar at 752px, preserving matching edges below the desktop cap.
The [composer stack contract](2026-07-30-composer-context-stack-order.md) continues to own inter-card spacing and Queue's exclusive overlap with the composer. This decision supersedes only that note's Goal-first order.
## Verification
Todo and Goal registration tests pin orders `0` and `10`; Queue remains pinned at `20`. The keyless Queue browser scenario renders all three panels concurrently, records their TodoGoalQueue accessibility order, and compares their visible bounding boxes at the 1680px desktop baseline and a 640px sub-cap viewport before exercising Queue mutations.
## Alternatives considered
**Reorder the known panels inside `ConversationRoot`.** Rejected because `conversation.input.dock` is an extensible ordered list; a hardcoded component inventory would make plugin activation order and rendered order disagree.
**Use CSS `order` to move Todo visually.** Rejected because accessibility and keyboard order must match the visual hierarchy, and the slot ledger already owns semantic order.
**Keep Todo at the Queue wrapper width.** Rejected because the Queue wrapper's transparent inset is layout infrastructure for its composer overlap, not part of the visible panel column.
## Consequences
The standing task plan appears before the ongoing goal, pending Queue work remains closest to the composer, and all three visible cards share one horizontal edge. Future input-dock plugins choose an explicit position relative to Todo `0`, Goal `10`, and Queue `20`; only Queue owns the terminal wrapper overlap.

View File

@@ -0,0 +1,33 @@
# Agent Note: Todo 优先的 composer 上下文顺序
Status: implemented
[English](2026-08-02-todo-first-composer-context-order.md) | 中文
## 问题
composer 上下文堆栈将 Goal 渲染在 Todo 之前,但 Harness 设计稿把当前任务计划排在进行中的目标和待处理 Queue 之前。Todo 还把 Queue 包装层的 776px 宽度用作自身的可见卡片宽度,而 Goal 和 Queue 面板则渲染在共享的 752px 卡片列上。结果既颠倒了预期的信息层级,也让 Todo 比相邻两个面板更宽。
## 决策
`conversation.input.dock` 列表采用统一的产品顺序,升序依次为 Todo `0`、Goal `10`、Queue `20`,随后是位于列表外的 composer bar。注册顺序仍是语义真源渲染器不会硬编码已知组件 id也不会使用 CSS 修正它们的顺序。
Todo、Goal 与可见的 Queue 面板共用 800px composer 宽度上限内的 752px 卡片列。Queue 保留 776px 包装层,并在两侧各留 12px 透明内缩,因为该包装层负责与 composer 重叠。Todo 是独立卡片而非包装层因此其响应式宽度和最大宽度都会直接扣除两层内缩。Goal 使用相同的响应式卡片列,并将内层横条的宽度上限设为 752px从而在低于桌面宽度上限时也保持边缘对齐。
[composer 堆栈契约](2026-07-30-composer-context-stack-order.md)继续规定卡片间距,以及仅限 Queue 与 composer 重叠。本决策只取代该记录中 Goal 优先的顺序。
## 验证
Todo 与 Goal 的注册测试分别固定顺序 `0``10`Queue 仍固定为 `20`。无密钥 Queue 浏览器场景同时渲染三个面板,记录 TodoGoalQueue 的无障碍顺序,并在 1680px 桌面基线和低于宽度上限的 640px 视口下比较其可见边界框,随后再执行 Queue 变更。
## 考虑过的替代方案
**在 `ConversationRoot` 内重新排列已知面板。** 不予采纳,因为 `conversation.input.dock` 是可扩展的有序列表;硬编码的组件清单会使插件激活顺序与渲染顺序不一致。
**使用 CSS `order` 移动 Todo 的视觉位置。** 不予采纳,因为无障碍顺序和键盘顺序必须与视觉层级一致,而 slot 账本已经负责语义顺序。
**让 Todo 保持 Queue 包装层的宽度。** 不予采纳,因为 Queue 包装层的透明内缩是其与 composer 重叠所需的布局基础设施,不属于可见面板列。
## 后果
当前有效的任务计划显示在进行中的目标之前,待处理 Queue 工作仍最靠近 composer三张可见卡片共用相同的横向边缘。未来的 input-dock 插件必须相对于 Todo `0`、Goal `10` 和 Queue `20` 选择明确位置;仅 Queue 负责末端包装层与 composer 的重叠。