Merge pull request #1096 from deepseek-harness/fix/web-fork-interrupted-seq

fix(client): floor the fork anchor to a real event seq
This commit is contained in:
Ziya
2026-08-01 02:09:06 -04:00
committed by GitHub
6 changed files with 119 additions and 5 deletions

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

View File

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

View File

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

View File

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

View File

@@ -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' }])

View File

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