Merge branch 'master' into fs-overwrite-diff-bound-v2

This commit is contained in:
Ziya
2026-08-08 05:17:04 -07:00
committed by GitHub
54 changed files with 961 additions and 120 deletions

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/architecture/2026-07-16-explicit-turn-cancellation.md
2026-07-16-explicit-turn-cancellation.md: ca56c77a097e3008a50c2aec24040a4f4b6f0ba3
2026-07-16-explicit-turn-cancellation.zh.md: bf410e5c7284a9c9914edbd14445074e71dd6943
2026-07-16-explicit-turn-cancellation.md: 2b7cb8cc77184edf1331764d28aefe748c1614a1
2026-07-16-explicit-turn-cancellation.zh.md: 68089e2c48d239afbff4c10cba5a202b4b6ff262

View File

@@ -18,7 +18,7 @@ An interrupted live turn ends with the coarse durable `{ kind: 'aborted' }` outc
AgentLoop privately owns one `TurnCancellation` per prospective turn. It installs the holder before notifying `agent/status = running`, retains its single `AbortController` through inbox claim, `agent/pre-step`, prompt assembly, every step, model and tool execution, and `agent/turn-stopping`, then clears the exact holder immediately before publishing `turn/end`. Terminal event observers and the following durability flush therefore cannot cancel already-completed turn work even though driver status may remain `running` until the flush settles. Every participating method, event, and request value receives that same explicit signal; the next turn receives a fresh signal.
The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work.
The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer is latched and runs when the aborted activity converges to idle — a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work.
The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining seams keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn.

View File

@@ -18,7 +18,7 @@ Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user
AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖 inbox 领取、`agent/pre-step`、提示词组装、每个步骤、模型与工具执行以及 `agent/turn-stopping`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。
对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。
对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。
显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 seam 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。

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-07-cancel-convergence-wake-latch.md
2026-08-07-cancel-convergence-wake-latch.md: fe00c78bdfadac0cc6c9c173fd04256f77a22051
2026-08-07-cancel-convergence-wake-latch.zh.md: f76ad360e2d8b70f76c6baf53e2fde963a80b6c4

View File

@@ -0,0 +1,29 @@
# Agent Note: Latch wake-ups that land in the cancel-convergence window
Status: implemented
English | [中文](2026-08-07-cancel-convergence-wake-latch.zh.md)
## Problem
`Agent.cancel(cause, { keepInbox: true })` returns immediately after firing the abort signal, but the active driver may not have converged to `idle` yet: LLM stream teardown, tool cancellation, and the `turn/end` append all unwind asynchronously after `abort()` returns. A waking send arriving in that window was placed into `next-turn` while `wakeDriver()` returned early on the still-`running` phase, and the exiting driver never replayed the wake — the message stayed parked until another waking send arrived. The same dropped-wake window existed around aborted `runMaintenance` activities. Several tests enshrined the parked behavior ("waits for another wakeup"); the bug broke both `session.cancel` and the `subagent.interrupt` composition path (issue #1838). The owning cancellation and send contracts are the [explicit turn cancellation](../architecture/2026-07-16-explicit-turn-cancellation.md) and [unified send](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) decisions; the production `keepInbox` consumer is [web stop preserves queue](2026-07-31-web-stop-preserves-queue.md).
## Decision
The `running` phase carries a `wakeRequested` latch, mirroring the existing `maintenance` phase field. `wakeDriver()` latches whenever the current activity cannot deliver the wake — a maintenance task never reads the queue, and an aborted activity converges without restarting — while a live driver needs no latch because it claims queued work itself. The exiting activity replays the latch at its own convergence boundary (`kick`'s `finally` and `runMaintenance`'s `finally`): this placement guarantees `turn/end N` lands before the replayed driver opens `turn/start N+1`, and that `whenIdle()` sees the replayed driver through its `activityDone` loop. The replay sites run only while `inbox.hasPending`, so a latched wake removed from the inbox before convergence does not start an empty driver. A wake sent while the agent is already idle keeps its turn boundary even when its message is cleared before the driver claims — that `idle → running → idle` transition is an observable contract: the goal-session driver's pause/disarm fallback fires on the `idle` transition after a cancelled reservation (CI caught this when the guard was moved into `wakeDriver()` and suppressed the boundary). `cancel()` without `keepInbox` clears the latch together with the inbox.
The `signal.aborted` discriminator is load-bearing: it separates pre-abort queued work — which `keepInbox` parks for a later wake (acceptance criterion 1) — from post-abort explicit wakes, which must run after convergence.
## Alternatives considered
**Have `cancel()` set the phase to `idle` immediately.** Rejected: the driver is still unwinding, so this overlaps two drivers. The replay lives in the old driver's `finally`, which then never runs — 14 of 83 tests failed, several deadlocked. Repairing it requires identity-based phase ownership plus a turn-open quiescence barrier, which is strictly more machinery and is the latch in disguise.
**Latch unconditionally for every non-idle wake.** Rejected: pre-abort wakes would auto-start after a `keepInbox` cancel, violating acceptance criterion 1; the "parks queued work" test and the error-window steering test both failed.
**Replay through a chained promise (`activityDone.then(...)`).** Rejected: the replay would run outside the activity's own settlement, so `whenIdle()`'s loop can resolve before the replayed driver starts; fixing that requires replacing `activityDone` at send time and depends on microtask reaction ordering — more fragile than a synchronous flag.
**Wait for quiescence in the subagent adapter.** Rejected by the issue scope: the cancel/wake state machine owns the fix, not a consumer.
## Consequences
The `running` phase gains a `wakeRequested` field; `cancel()` without `keepInbox` clears it alongside the inbox, and a `disposed` cancel never latches, so a wake landing after disposal begins stays parked and `whenIdle()` does not wait on a full model turn over the session being torn down. A wake arriving in the sub-microtask gap between the driver's final `hasPending` check and its exit still parks — no latch fires because the phase is `running` and not aborted; closing that gap requires the unconditional latch and is deliberately out of scope. Between the aborted turn and the replayed driver, status transitions emit a transient `idle → running` pair. A waking send whose message is cleared before any driver claims it still opens an empty completed turn, preserving the observable wake boundary.

View File

@@ -0,0 +1,29 @@
# Agent Note: 锁存取消收敛窗口内到达的唤醒请求
Status: implemented
[English](2026-08-07-cancel-convergence-wake-latch.md) | 中文
## 问题
`Agent.cancel(cause, { keepInbox: true })` 在触发 abort 信号后立即返回,但活动 driver 可能尚未收敛到 `idle`:LLM 流拆除、工具取消与 `turn/end` 落盘都会在 `abort()` 返回后异步展开。在该窗口内到达的唤醒 send 被放入 `next-turn`,而 `wakeDriver()` 对仍处于 `running` 的 phase 直接返回,退出的 driver 也从不重放这次唤醒——消息会一直停放到下一条唤醒 send 到达。被中止的 `runMaintenance` 活动周围也存在同样的唤醒丢失窗口。多个测试固化了停放行为(「等待下一次唤醒」);该缺陷同时破坏了 `session.cancel` 与 `subagent.interrupt` 组合路径(issue #1838)。拥有取消与发送契约的既有决策是[显式轮次取消](../architecture/2026-07-16-explicit-turn-cancellation.md)与[统一发送](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md);生产环境中的 `keepInbox` 消费方是[Web 停止保留队列](2026-07-31-web-stop-preserves-queue.md)。
## 决策
`running` phase 携带 `wakeRequested` 锁存,与既有的 `maintenance` phase 字段对称。`wakeDriver()` 在当前活动无法投递唤醒时锁存——maintenance 任务从不读取队列,被中止的活动收敛后不会重启——而存活的 driver 不需要锁存,因为它自己会认领排队的工作。退出中的活动在其自身收敛边界(`kick` 的 `finally` 与 `runMaintenance` 的 `finally`)重放锁存:这一位置保证 `turn/end N` 先于重放 driver 打开 `turn/start N+1` 落盘,并保证 `whenIdle()` 通过其 `activityDone` 循环看到重放 driver。两个重放点仅在 `inbox.hasPending` 时执行,因此收敛前被从 inbox 移除的锁存唤醒不会启动空 driver。而 agent 已处于 idle 时发送的唤醒,即使消息在 driver 认领前被清除,仍会打开自己的 turn 边界——这趟 `idle → running → idle` 转换是可观察契约:goal-session driver 的 pause/disarm 回退依赖取消预订后的 `idle` 转换触发(把守卫放进 `wakeDriver()` 后该边界被抑制,CI 发现了这一点)。不带 `keepInbox` 的 `cancel()` 会连同 inbox 一起清除锁存。
`signal.aborted` 判别项是承重的:它区分「中断前已排队的工作」——`keepInbox` 将其停放以待后续唤醒(验收条件 1)——与「abort 后显式的唤醒」,后者必须在收敛后执行。
## 备选方案
**让 `cancel()` 立即把 phase 置为 `idle`。** 不予采用:driver 仍在展开收尾,这会重叠两个 driver。重放逻辑位于旧 driver 的 `finally`,而该 `finally` 此后不再执行——83 个测试中有 14 个失败,多个死锁。修复它需要基于身份的 phase 所有权外加 turn 打开时的 quiescence 屏障,机制上严格更重,而且该屏障就是换了个形态的锁存。
**对每个非 idle 唤醒无条件锁存。** 不予采用:中断前的唤醒会在 `keepInbox` 取消后自动启动,违反验收条件 1;「停放排队工作」测试与错误窗口的 steering 测试双双失败。
**通过链式 promise(`activityDone.then(...)`)重放。** 不予采用:重放会运行在活动自身结算之外,`whenIdle()` 的循环可能在重放 driver 启动前就 resolve;修复它需要在 send 时同步替换 `activityDone`,并依赖微任务反应顺序——比同步 flag 更脆弱。
**在 subagent adapter 中等待 quiescence。** 被 issue 范围否决:修复由取消/唤醒状态机拥有,而不是消费方。
## 影响
`running` phase 新增 `wakeRequested` 字段;不带 `keepInbox` 的 `cancel()` 会连同 inbox 一起清除它,且 `disposed` 取消从不锁存——dispose 开始后到达的唤醒保持停放,`whenIdle()` 不会在拆除中的会话上等待一个完整模型 turn。落在 driver 最后一次 `hasPending` 检查与退出之间微任务间隙的唤醒仍会停放——没有锁存触发,因为 phase 是 `running` 且未 abort;关闭该间隙需要无条件锁存,刻意留作范围外。在被中止的 turn 与重放 driver 之间,状态转换会发出一次瞬态 `idle → running` 对。唤醒 send 的消息在任何 driver 认领前被清除时,仍会打开一个空的 completed turn,保留可观察的唤醒边界。

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/feature/2026-08-07-web-inline-file-mentions.md
2026-08-07-web-inline-file-mentions.md: 581efb5a9eb497e030d19118d52d003b37118108
2026-08-07-web-inline-file-mentions.zh.md: 25c8ca6e106cd877a4d46a7b1338728e96771fc1

View File

@@ -0,0 +1,28 @@
# Agent Note: inline-code file mentions open the file they name
Status: implemented
English | [中文](2026-08-07-web-inline-file-mentions.zh.md)
> Scope: linking inline-code tokens in the closing message's prose to the files the turn produced. Not in scope: recognizing paths in plain prose, linking files the turn did not produce, and mentions in streaming or mid-turn messages.
## Problem
The produced-files row lists a turn's output, but the closing message usually also *names* the file in prose — as inline code, like `` `deepseek-homepage.html` `` — and that mention was inert text. The reader's eye lands on the sentence first; the affordance sat one row below it.
## Decision
**A prose mention links only when it matches a produced file.** The [produced-files decision](2026-07-31-web-workspace-file-links.md) rejected linkifying the closing message because rendering must not depend on the model spelling a path recognizably; that holds. The row remains the authoritative, prose-independent account. This feature adds a second consumer of the same `locations` vocabulary: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path. A basename two paths share stays inert rather than guessing, and a token naming nothing the turn wrote stays inert — a mention link can never 404.
**The renderer owns no vocabulary, and the provider is the deliverables plugin.** `MarkdownText` takes an optional `MarkdownFileMentions` resolver and consults it for inline-code tokens — after URL promotion, which wins, and never inside an anchor, where a button cannot nest. What names a file is decided behind the optional `chatFileMentions` service ui-conversation reaches via `ctx.get`: ui-deliverables provides it beside its turn-tail chain entry, so one cordis.yml line composes the row and the prose links in or out together, and ui-primitives gains no session concepts. Mentions apply to settled renders only — the streaming cache must not bake in handlers that could go stale, and the vocabulary is not final until the turn closes. The consumer memoizes the resolver on the closing seq rather than the growing transcript, so a settled message's cached parse survives stream appends.
## Alternatives considered
- **Path-shaped regex over all prose** — links `package.json` mentioned abstractly and examples that were never written; every false positive is a click that opens nothing or the wrong file. The vocabulary approach cannot produce a dead link.
- **Linking suffix matches (`out/index.html` mentioned as `index.html` in a subdirectory listing)** — deferred; exact path and unique basename cover the observed closing-message shapes, and a wider matcher can loosen later without breaking the seam.
- **Resolving in ui-primitives against a passed path list** — puts matching policy in the generic renderer, where other consumers would inherit it unasked. The resolver seam keeps policy with the owner.
- **Threading the vocabulary through the turn-tail chain** — the chain is a render dispatch below the message; mentions decorate markdown inside it, which only data reaching MarkdownText can do. The optional service is that data path, and its absence is the off state.
## Consequences
The mention and the row are two affordances for one fact (full path as `title` on both); the mention itself wears the markdown sheet's anchor language — link-blue at rest, hover underline — because an at-rest underline collides with monospace descenders inside the code chip. `apps/web/tests/produced-file-mentions.e2e.ts` pins the assembled behavior with a built write-turn seed: unique basename links, ambiguous and unknown tokens stay inert; it does not click, for the produced-files restraint (the opener launches a real application). Mentions in mid-turn narration stay inert even for files the turn later produces, because the vocabulary attaches to the closing message only. The window-prepend edge — a window that starts mid-turn later gaining earlier same-turn writes — leaves a mention unlinked until remount, never wrongly linked.

View File

@@ -0,0 +1,28 @@
# Agent Note:行内代码文件提及可打开其命名的文件
Status: implemented
[English](2026-08-07-web-inline-file-mentions.md) | 中文
> 范围:把收尾消息正文中的行内代码 token 链接到本轮产出的文件。不在范围内:识别普通正文中的路径、链接本轮未产出的文件,以及流式或轮次中途消息里的提及。
## 问题
产物行列出了一轮的输出,但收尾消息通常也会在正文里*点名*文件——以行内代码形式,如 `` `deepseek-homepage.html` ``——而这个提及是死文本。读者的视线先落在句子上;可点击的交互却在下面一行。
## 决定
**正文提及只在与产出文件对得上时才成为链接。**[产物行的决定](2026-07-31-web-workspace-file-links.md)否决过"把收尾消息链接化",理由是渲染不能依赖模型把路径写得可识别;这一点不变。产物行仍是权威的、不依赖正文的记录。本特性只是给同一份 `locations` 词表增加第二个消费者:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析。两条路径共享的 basename 保持死文本而不猜测,命名了本轮没写过的文件的 token 同样保持死文本——提及链接永远不会 404。
**渲染器不持有词表,提供方是 deliverables 插件。**`MarkdownText` 接受可选的 `MarkdownFileMentions` 解析器,对行内代码 token 询问它——URL 提升优先于解析器,且绝不在锚点内部(按钮不能嵌套在链接里)。什么算文件名的决定藏在 ui-conversation 经 `ctx.get` 触达的可选 `chatFileMentions` service 背后:ui-deliverables 在其 turn-tail chain 注册项旁提供该 service,因此 cordis.yml 中的一行同时把产物行和正文链接组合进来或去掉,ui-primitives 不引入任何会话概念。提及只作用于已定稿的渲染——流式缓存不能烘进可能过期的 handler,而且词表在轮次收尾前并不最终。消费方按收尾 seq 而非不断增长的 transcript 记忆化解析器,因此已定稿消息的缓存解析在流式追加中得以保留。
## 考虑过的替代方案
- **对全部正文跑路径形状的正则**——会把随口提到的 `package.json` 和从未写过的示例都链接上;每个误报都是一次打开空无或错误文件的点击。词表方案不可能产生死链。
- **链接后缀匹配(子目录列表里把 `out/index.html` 写作 `index.html`)**——暂缓;精确路径加唯一 basename 已覆盖观察到的收尾消息形态,之后放宽匹配器不会破坏这道 seam。
- **在 ui-primitives 里对传入的路径列表做解析**——把匹配策略放进通用渲染器,其他消费方会被动继承。解析器 seam 让策略留在持有者手里。
- **经由 turn-tail chain 传递词表**——chain 是消息下方的渲染派发;提及要装饰的是消息内部的 markdown,只有抵达 MarkdownText 的数据才做得到。可选 service 就是那条数据通路,它的缺席即关闭态。
## 后果
提及与产物行是同一事实的两个交互面(两者都以完整路径作 `title`);提及本身采用 markdown 样式表的锚点语言——静止为链接蓝、悬停出下划线——因为静止下划线在 code 胶囊里会压住等宽字的下伸部。`apps/web/tests/produced-file-mentions.e2e.ts` 用构造的写入轮 seed 钉住组装后的行为:唯一 basename 成链,歧义与未知 token 保持死文本;它不驱动点击,沿用产物行的克制(opener 会启动真实应用)。轮次中途叙述里的提及即使命名了本轮后来产出的文件也保持死文本,因为词表只挂在收尾消息上。窗口前插的边界——从轮次中途开始的窗口后来补入了同轮更早的写入——只会让提及在重挂载前暂不成链,绝不会错链。