diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.i18n.yaml new file mode 100644 index 0000000000..c4a5595fea --- /dev/null +++ b/.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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.md b/.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.md new file mode 100644 index 0000000000..2cc8d66942 --- /dev/null +++ b/.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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-fork-anchor-floors-to-event-seq.zh.md new file mode 100644 index 0000000000..9f443f3d51 --- /dev/null +++ b/.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 上是另一件事。 diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 93ecb3c791..fcc89930fa 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/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() diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 9fabb0d8de..eb12fd71cb 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/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' }]) diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index 797bca29f2..57f854c1ea 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -46,7 +46,10 @@ async function composed(): Promise { 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({