Fork anchor floors to an event seq
fork 锚点向下取整到事件 seq
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 poi
English
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
SessionRuntime.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.
中文
问题
在已停止的助手消息上点 fork 毫无反应——没有子会话,没有报错,也没有任何可见变化。
这条消息背后的冻结节点并不是日志事件。实时投影和历史回放都用 turnEnd.seq - 0.9 这个排序坐标来生成它,让它严格落在被中断轮次的所有事件之后、下一轮之前,而 chat 视图原样把这个节点 seq 交给 fork 入口。session.fork 在 wire 上只接受非负整数,因此分数锚点在抵达 host 之前就被判为 invalid-params,而 chat 入口的 fork 调用又吞掉了失败。于是被拒绝和按钮失灵在表现上毫无区别。
host 的切分规则从来不是障碍。被中止的轮次会记录一条 reason 为 aborted 的 turn/end,它和其他轮次一样是可切分的完整前缀——只是锚点根本没送到。
决策
SessionRuntime.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 上是另一件事。