fix(client): floor the fork anchor to a real event seq
The fork button on a stopped assistant message was inert. Frozen interrupted nodes carry a flow-ordering seq of turnEnd.seq - 0.9, and session.fork takes a non-negative integer on the wire, so every such request was rejected as invalid-params before reaching the host — where an aborted turn's logged turn/end has always made it forkable. SessionsService.fork floors atSeq at the wire boundary. Flooring stays inside the anchor's own turn (every turn opens with turn/start), so the host's first-turn/end-at-or-after cut still closes on that turn.
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 上是另一件事。
|
||||
Reference in New Issue
Block a user