diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index d46f971570..2c2bbf17cc 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index d128f25b54..3e58adb5ce 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -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. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 09bd50dbaa..259c1be08d 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -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`。 diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index a52d2c155a..c1fede0937 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -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. */ diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 4b70888468..86b1ad4757 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -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 { + 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 diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 4301be69d6..cf3a6b94cb 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -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' }]) diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 00d17ca655..1d444f3c2f 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -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', 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.'], },