Merge pull request #7472 from deepseek-harness/turtle/fork-name-suffix-5f3435925211

fix(session-controller): give repeated forks distinct titles
This commit is contained in:
Turtle
2026-11-04 14:37:57 +08:00
committed by GitHub
7 changed files with 54 additions and 13 deletions
@@ -15,8 +15,8 @@
en: 1fdf707322fb6996
zh: 0204f7e9d4aa3cce
/session-controller/use-this-package:
en: 300f02be359a3170
zh: ac7fb4abbe78eddf
en: 425f679cb90f481b
zh: 15ed208a442b5b3b
/session-controller/client-references:
en: e8979a803ec3e194
zh: c567efcd971728ba
@@ -33,8 +33,8 @@
en: 22fc0c9dbedd3bb6
zh: fad040ca55fd0e0a
/session-controller/known-limitations-and-deferred-work:
en: 5db0f9f69a8be926
zh: 6ba1cc4a3850fcbc
en: 23bc3db43e84daa6
zh: 0242338ac7bd24fa
/session-controller/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: e88aeb617ffb74ea
+2 -1
View File
@@ -64,7 +64,7 @@ During uninterrupted following, transcript and steering echoes stay in Chat thro
The user-invocable `skills/list` metadata includes the winning provider’s optional instruction-file `path`. The composer can preview that file without loading every skill body or activating a cold Agent.
Fork copies the exact inclusive event prefix selected by `atSeq`, including a cut inside an open turn, into an independent Session without `parentSession`. Client placeholders likewise omit `parentSessionId`; list indentation applies only to subagent-origin rows, including when historical ordinary forks retain a parent id. Workspace attachment and optional title increments use the source selected for the operation. The child records its inherited marker before synthetic fork results and closing events. Omitting `atSeq` selects the latest completed turn and its standalone tail, stopping before the next turn or queued input; a nonexistent event is rejected. The chat action selects a completed turn.
Fork copies the exact inclusive event prefix selected by `atSeq`, including a cut inside an open turn, into an independent Session without `parentSession`. Client placeholders likewise omit `parentSessionId`; list indentation applies only to subagent-origin rows, including when historical ordinary forks retain a parent id. Workspace attachment and optional title increments use the source selected for the operation; an increment skips numbers that other Host-listed titles already use. The child records its inherited marker before synthetic fork results and closing events. Omitting `atSeq` selects the latest completed turn and its standalone tail, stopping before the next turn or queued input; a nonexistent event is rejected. The chat action selects a completed turn.
A resume blocked by an existing write handle returns `session/writer-held` with the Session id; other resume failures retain `gateway/internal`.
@@ -129,6 +129,7 @@ No direct effect; model requests remain owned by the Agent and LLM packages.
- The image byte cap does not validate decoded dimensions or pixel count.
- A failed follow resumption remains visible to the caller instead of retrying indefinitely.
- The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero.
- Fork title increments compare candidates with the Client list before the Host normalizes them: concurrent forks of one source, or a title the Host truncates at its byte limit, can still produce duplicate titles.
- File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata.
- Accepted/running display memory lives only in Client memory and is lost on page reload.
- That memory is not shared between tabs: one tab can show a converted row while another still shows `New Session` for the same Session.
+2 -1
View File
@@ -64,7 +64,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。
Fork 将 `atSeq` 选定的含边界事件前缀(包括开放轮次内的截点)复制到不带 `parentSession` 的独立 Session。客户端占位项同样省略 `parentSessionId`;列表缩进仅适用于 subagent 来源的行,历史普通 fork 即使保留父 ID 也不缩进。工作区绑定及可选标题递增使用本次操作选定的源会话。子会话在合成的 fork 结果和结束事件之前记录继承标记。省略 `atSeq` 时选择最近已结束轮次及其独立尾部,在下一轮次或排队输入之前停止;不存在的事件会被拒绝。聊天操作选择已结束轮次。
Fork 将 `atSeq` 选定的含边界事件前缀(包括开放轮次内的截点)复制到不带 `parentSession` 的独立 Session。客户端占位项同样省略 `parentSessionId`;列表缩进仅适用于 subagent 来源的行,历史普通 fork 即使保留父 ID 也不缩进。工作区绑定及可选标题递增使用本次操作选定的源会话;递增时会跳过其他由 Host 列出的标题已使用的编号。子会话在合成的 fork 结果和结束事件之前记录继承标记。省略 `atSeq` 时选择最近已结束轮次及其独立尾部,在下一轮次或排队输入之前停止;不存在的事件会被拒绝。聊天操作选择已结束轮次。
恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。
@@ -129,6 +129,7 @@ Blockquote 作为普通 prompt 文本传递,不携带 quote 元数据。本地
- 图片字节上限不校验解码后的尺寸或像素数。
- follow 恢复失败会对调用方可见,而不会无限重试。
- 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第零字节重新传输整个文件。
- Fork 标题递增会在 Host 规范化候选标题之前,先将其与客户端列表比较:同一来源的并发 Fork,或被 Host 按字节上限截断的标题,仍可能产生重复标题。
- 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。
- 受理/运行的展示记忆只存在于客户端内存,页面重载后即丢失。
- 该记忆不在 tab 之间共享:同一会话可能在一个 tab 中已转正,在另一个 tab 中仍显示为 `New Session`。
@@ -128,7 +128,8 @@ export interface ISessions {
* seq (a real event seq the caller already knows; a cut inside an open
* turn is balanced Host-side with synthetic closers, and omission selects
* the latest completed-turn prefix), and whether to increment an
* inherited durable title before resolving. `onCreated` observes the catalogued child before that optional rename.
* inherited durable title, skipping numbers Host-listed titles already use,
* before resolving. `onCreated` observes the catalogued child before that optional rename.
* @returns the child session id.
* @throws when the fork fails, or when a requested child-title rename fails after creation.
*/
@@ -130,12 +130,24 @@ function displayTitleOf(title: string | undefined, cwd: string | undefined, id:
}
/**
* Increment a trailing fork number while preserving its half-width or
* full-width parentheses; an unnumbered title starts with ` (1)`.
* Increment the fork number until the candidate is not taken.
* @param title - source session's durable title.
* @param taken - durable titles of Host-listed sessions.
* @returns the title assigned to the fork child.
*/
function increasedForkTitle(title: string): string {
function increasedForkTitle(title: string, taken: ReadonlySet<string>): string {
let next = incrementedTitle(title)
while (taken.has(next)) next = incrementedTitle(next)
return next
}
/**
* Increment a trailing fork number while preserving its half-width or
* full-width parentheses; an unnumbered title starts with ` (1)`.
* @param title - title to increment.
* @returns the title with the next fork number.
*/
function incrementedTitle(title: string): string {
const ascii = /^(.*?)\((\d+)\)$/u.exec(title)
if (ascii?.[1] !== undefined && ascii[2] !== undefined) {
return `${ascii[1]}(${BigInt(ascii[2]) + 1n})`
@@ -455,7 +467,8 @@ export class ClientSessions implements ISessions {
* seq (a real event seq the caller already knows; a cut inside an open
* turn is balanced Host-side with synthetic closers, and omission selects
* the latest completed-turn prefix), and whether to increment an
* inherited durable title before resolving, plus permission to start source migration.
* inherited durable title, skipping numbers Host-listed titles already use,
* before resolving, plus permission to start source migration.
* @returns the child session id.
* @throws {SessionForkError} with the source id.
* @throws {Error} when a requested child-title rename fails after creation.
@@ -480,7 +493,9 @@ export class ClientSessions implements ISessions {
const childId = result.value.sessionId
opts.onCreated?.(childId)
if (sourceTitle !== undefined) {
const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle))
const list = this.list.getSnapshot()
const taken = new Set(list.ids.flatMap(id => list.byId[id]?.title ?? []))
const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle, taken))
if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
}
return childId
@@ -1010,6 +1010,29 @@ describe('fork', () => {
})
})
it('gives repeated forks distinct titles past numbers listed sessions use', async ({ bench }) => {
const b = bench()
for (const [id, value] of [['source', 'Roadmap'], ['sibling', 'Roadmap (2)']] as const) {
b.svc.handleControlFrame({ type: 'projection', sessionId: sid(id), changes: [{ key: 'title', val: value }], seq: 2 })
}
await feedList(b, [{ id: 'source' }, { id: 'sibling' }])
b.mock.remote.session.rename.mockImplementation((payload) => {
const { title } = payload as { title: string }
return Promise.resolve(ok({ title, seq: 3 }))
})
for (const child of ['child-1', 'child-2']) {
b.mock.remote.session.fork.mockResolvedValueOnce(ok({ sessionId: sid(child) }))
await b.svc.fork({ sessionId: sid('source'), increaseTitle: true })
}
expect(b.mock.remote.session.rename.mock.calls).toEqual([
[{ sessionId: 'child-1', title: 'Roadmap (1)' }],
[{ sessionId: 'child-2', title: 'Roadmap (3)' }],
])
expect(b.svc.list.getSnapshot().byId[sid('child-2')]?.title).toBe('Roadmap (3)')
})
it('sends the exact boundary seq verbatim: callers pass real event seqs', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])
@@ -215,7 +215,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
signature: 'fork(opts: { sessionId: SessionId atSeq?: number increaseTitle?: boolean onCreated?: (childId: SessionId) => void /** False refuses source migration; omission preserves the Host default. */ allowMigration?: boolean }): Promise<SessionId>',
description: 'Fork a session from an exact inclusive prefix of the source; on resolution the child is catalogued and can be explicitly retained.',
parameters: [{ name: 'opts', description: 'source session id, the optional exact inclusive boundary seq (a real event seq the caller already knows; a cut inside an open turn is balanced Host-side with synthetic closers, and omission selects the latest completed-turn prefix), and whether to increment an inherited durable title before resolving. `onCreated` observes the catalogued child before that optional rename.' }],
parameters: [{ name: 'opts', description: 'source session id, the optional exact inclusive boundary seq (a real event seq the caller already knows; a cut inside an open turn is balanced Host-side with synthetic closers, and omission selects the latest completed-turn prefix), and whether to increment an inherited durable title, skipping numbers Host-listed titles already use, before resolving. `onCreated` observes the catalogued child before that optional rename.' }],
returns: 'the child session id.',
throws: ['when the fork fails, or when a requested child-title rename fails after creation.'],
},