Jelajahi Sumber

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.
ZiyaZhang 1 bulan lalu
induk
melakukan
344ad0d6fb

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.i18n.yaml

@@ -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

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.md

@@ -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.

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.zh.md

@@ -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 上是另一件事。

+ 7 - 1
packages/client/runtime/src/client/sessions/service.ts

@@ -388,6 +388,9 @@ export class SessionsService implements ISessions {
    *   cut (the boundary is the first turn/end at or after it; an in-log
    *   anchor in an open turn is unavailable rather than clipped backward),
    *   and whether to increment an inherited durable title before resolving.
+   *   A fractional anchor floors to a real event seq: the frozen nodes of an
+   *   interrupted turn carry flow-ordering seqs between two events, and the
+   *   wire takes integers only.
    * @returns the child session id.
    * @throws {SessionForkError} with the source id.
    * @throws {Error} when a requested child-title rename fails after creation.
@@ -402,7 +405,10 @@ export class SessionsService implements ISessions {
       : undefined
     const result = await this.manager.fork({
       sessionId: opts.sessionId,
-      ...(opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }),
+      // Flooring lands inside the anchor's own turn (every turn opens with a
+      // turn/start), so the host's first-turn/end-at-or-after cut still ends
+      // on that turn — never clipped back to the previous one.
+      ...(opts.atSeq === undefined ? {} : { atSeq: Math.floor(opts.atSeq) }),
     })
     if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
     this.projectList()

+ 11 - 0
packages/client/runtime/tests/sessions-service.spec.ts

@@ -455,6 +455,17 @@ describe('fork', () => {
     })
   })
 
+  it('floors a fractional anchor to the real event seq the wire accepts', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 'source', cwd: '/work' }])
+    b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') }))
+
+    // The frozen node of an interrupted turn carries turnEnd.seq - 0.9.
+    await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child')
+
+    expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 41 }])
+  })
+
   it('does not rename without the title policy or a durable source title', async () => {
     const b = bench()
     await feedList(b, [{ id: 'source', cwd: '/work' }])

+ 25 - 4
packages/host/apiproxy/tests/api-proxy-fork.spec.ts

@@ -46,7 +46,10 @@ async function composed(): Promise<Context> {
   return ctx
 }
 
-function liveAgent(ctx: Context, id: string, turns: number, openTail = false): Session {
+/** Tail turn appended after the completed ones: left open, or closed as aborted (a stopped turn). */
+type Tail = 'none' | 'open' | 'aborted'
+
+function liveAgent(ctx: Context, id: string, turns: number, tail: Tail = 'none'): Session {
   const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
   for (let turn = 1; turn <= turns; turn++) {
     session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -56,12 +59,13 @@ function liveAgent(ctx: Context, id: string, turns: number, openTail = false): S
     }), { surfaceOp: 'append' })
     session.append('turn/end', { turn, reason: { kind: 'completed' } })
   }
-  if (openTail) {
+  if (tail !== 'none') {
     session.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     session.append('user/message', createUserMessage({
       content: [{ type: 'text', text: 'open prompt' }],
       source: { kind: 'user' },
     }), { surfaceOp: 'append' })
+    if (tail === 'aborted') session.append('turn/end', { turn: turns + 1, reason: { kind: 'aborted' } })
   }
   ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
   return session
@@ -92,7 +96,7 @@ describe('sessions.fork', () => {
 
   it('uses the last completed turn only for omitted and past-end anchors', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-tail', 2, true)
+    const source = liveAgent(ctx, 'session-tail', 2, 'open')
     const proxy = api(ctx)
     const expectedTypes = [
       'turn/start', 'user/message', 'turn/end',
@@ -114,9 +118,26 @@ describe('sessions.fork', () => {
     await ctx.fiber.dispose()
   })
 
+  it('cuts through an aborted turn: stopped is closed, not open', async () => {
+    const ctx = await composed()
+    const source = liveAgent(ctx, 'session-aborted', 1, 'aborted')
+    // What a stopped message's fork button anchors on: the frozen node sits
+    // one event before its turn/end, floored client-side to that event's seq.
+    const anchor = (source.events.at(-1)?.seq ?? 0) - 1
+    const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor }))
+    expect(response.result.ok).toBe(true)
+    if (!response.result.ok) return
+    expect(ctx.sessions.get(response.result.value.sessionId)?.events.map(event => event.type)).toEqual([
+      'turn/start', 'user/message', 'turn/end',
+      'turn/start', 'user/message', 'turn/end',
+      'session/end-seed',
+    ])
+    await ctx.fiber.dispose()
+  })
+
   it('rejects an in-log anchor whose turn is still open', async () => {
     const ctx = await composed()
-    const source = liveAgent(ctx, 'session-open', 1, true)
+    const source = liveAgent(ctx, 'session-open', 1, 'open')
     const anchor = source.events.at(-1)?.seq ?? 0
     const response = await api(ctx).sessions.fork(request({ sessionId: source.id, atSeq: anchor }))
     expect(response.result).toMatchObject({