fix(session): retain fork tail until the first inbox change

This commit is contained in:
Dudu-0223 committed 2026-09-11 14:58:46 +08:00
1 parent 896a6c3e47
commit 0f8552df7f
11 files changed
+55 -34

No files matched your search

@@ -2,5 +2,5 @@
# 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-09-11-session-controller-fork-turn-cut.md
2026-09-11-session-controller-fork-turn-cut.md: 0c8d45c861df964a62586827e80c613704ed17f0
2026-09-11-session-controller-fork-turn-cut.zh.md: 729a42a596264ac618608d26cd52a1ad836b58db
2026-09-11-session-controller-fork-turn-cut.md: 47f48e60fa3f34a766e5e3ecf21f3e95502384c3
2026-09-11-session-controller-fork-turn-cut.zh.md: 1d109bfb33b2a8eadcad63feeb99a11562540afb
@@ -1,4 +1,4 @@
# Agent Note: Session Controller forks stop at the selected turn end
# Agent Note: Session Controller forks exclude the next inbox change
Status: implemented
@@ -10,20 +10,20 @@ A user input enters the durable inbox before its `turn/start`. Extending a compl
## Decision
The [Session Controller](../../../../packages/api/session-controller/README.md) copies the contiguous prefix through the selected `turn/end`, inclusive. Explicit anchors select the first closing event at or after the anchor; omitted and past-end anchors select the last closing event. No event after that closing event belongs to the seed, including queued input, titles, and model settings.
The [Session Controller](../../../../packages/api/session-controller/README.md) selects a completed `turn/end` and copies its contiguous prefix plus following events up to, but excluding, the first `turn/start` or `agent/inbox/spliced`. Explicit anchors select the first closing event at or after the anchor; omitted and past-end anchors select the last closing event. Both next-turn and next-step inbox changes stop the extension.
The lower-level `SessionStore.fork()` retains its explicit stable-event semantics from the [log-only event decision](../simplification/2026-07-28-remove-synthetic-log-only-turns.md). Selecting a completed turn in the controller does not request a later stable event.
This retains title and model-setting events before the first inbox change, consistent with the [log-only event decision](../simplification/2026-07-28-remove-synthetic-log-only-turns.md). The lower-level `SessionStore.fork()` retains its explicit stable-event semantics.
## Alternatives considered
**Stop only at the next inbox event.** Event-type exceptions still copy unrelated state changes after the selected turn and require the controller to classify plugin-owned events.
**Stop exactly at the selected turn end.** This drops standalone title and model-setting events recorded after the turn even when no input follows them.
**Copy the tail and clear the child's inbox.** Clearing adds child events to cancel input that lies outside the requested prefix, while still inheriting other state from after the selected turn.
**Copy the tail and clear the child's inbox.** Clearing adds child events to cancel input that the fork can exclude from its seed.
## Consequences
A fork inherits the configuration recorded through its selected turn. Later configuration and title events are excluded. The client may independently assign the child's fork title. Events already inside the selected prefix retain their ordinary replay semantics; this decision does not redefine pending input inserted before the selected closing event.
An asynchronous title or plugin event can occur after an inbox change; the fork excludes it along with the rest of that tail. Event ordering does not guarantee that every late plugin result is inherited. The client independently assigns a fork title from the displayed source title when available. Events already inside the selected completed-turn prefix retain their ordinary replay semantics; this decision does not redefine pending input inserted before the selected closing event.
## Verification
Controller tests execute the production loop and check that sending C after forking A excludes the parent's later B from child history and produces one model request. Message, closing-event, omitted, and past-end anchors share this assertion. Model-routing coverage excludes a later configuration change. The Web message-actions snapshot seeds a queued input after the completed turn and verifies the branch action creates a child without that input or its inbox insertion.
Controller tests execute the production loop and check that sending C after forking A excludes the parent's later B from child history and produces one model request, while retaining a title between A's end and B's insertion. Message, closing-event, omitted, and past-end anchors share this assertion. Model-routing coverage retains settings before either inbox destination and excludes later titles and settings. The Web message-actions snapshot places B's inbox insertion between completed turns and verifies the branch action creates a child without B or its insertion.
@@ -1,4 +1,4 @@
# Agent Note: Session Controller 分叉截到选中轮次的结束事件
# Agent Note: Session Controller 分叉排除下一次 inbox 变更
Status: implemented
@@ -10,20 +10,20 @@ English | [中文](2026-09-11-session-controller-fork-turn-cut.md)
## Decision
[Session Controller](../../../../packages/api/session-controller/README.zh.md) 复制截至选中 `turn/end` 的连续前缀,并包含该事件。显式锚点选择位于锚点或其后的第一条结束事件;省略锚点或锚点超出日志末尾时,选择最后一条结束事件。结束事件之后的所有事件均不属于种子,包括排队输入、标题和模型设置。
[Session Controller](../../../../packages/api/session-controller/README.zh.md) 选择已结束的 `turn/end`,复制其连续前缀及后续事件,直到第一条 `turn/start` 或 `agent/inbox/spliced` 之前,不包含该停止事件。显式锚点选择位于锚点或其后的第一条结束事件;省略锚点或锚点超出日志末尾时,选择最后一条结束事件。next-turn 和 next-step 两种 inbox 变更都会停止扩展。
底层 `SessionStore.fork()` 保留[纯日志事件决策](../simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md)规定的显式稳定事件语义。在控制器中选择已结束轮次,不代表请求复制到更晚的稳定事件。
这会保留第一次 inbox 变更之前的标题和模型设置事件,与[纯日志事件决策](../simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md)一致。底层 `SessionStore.fork()` 保留其显式稳定事件语义。
## Alternatives considered
**仅在下一条 inbox 事件处停止。** 按事件类型添加例外仍会复制选中轮次之后的无关状态变化,并要求控制器分类插件所属的事件。
**严格截到选中轮次的结束事件。** 即使后面没有输入,这也会丢弃轮次结束后记录的独立标题和模型设置事件。
**复制尾部后清空子会话 inbox。** 清空操作会添加子会话事件,以取消本来就在所请求前缀之外的输入,同时仍然继承选中轮次之后的其他状态。
**复制尾部后清空子会话 inbox。** 清空操作会添加子会话事件,以取消本可从分叉种子中排除的输入。
## Consequences
分叉继承截至选中轮次记录的配置。之后的配置和标题事件被排除。客户端可以独立设置子会话的分叉标题。已经位于选中前缀内的事件保留通常的回放语义;本决策不重新定义在选中结束事件之前入队的待处理输入。
异步标题或插件事件可能出现在 inbox 变更之后;分叉会将它与该尾部的其余事件一起排除。事件顺序不保证每个延迟的插件结果都会被继承。显示的源标题可用时,客户端会据此独立设置分叉标题。已经位于选中已结束轮次前缀内的事件保留通常的回放语义;本决策不重新定义在选中结束事件之前入队的待处理输入。
## Verification
控制器测试执行生产循环,检查从 A 分叉后发送 C 时,子会话历史不包含父会话后续的 B,并且只产生一次模型请求。消息、结束事件、省略及越界锚点共用此断言。模型路由覆盖排除了后续配置变更。Web 消息操作快照在已结束轮次后植入排队输入,验证分叉操作创建的子会话不包含该输入或其入队事件。
控制器测试执行生产循环,检查从 A 分叉后发送 C 时,子会话历史不包含父会话后续的 B,并且只产生一次模型请求,同时保留 A 结束与 B 入队之间的标题。消息、结束事件、省略及越界锚点共用此断言。模型路由覆盖保留两种 inbox 目标变更之前的设置,并排除之后的标题和设置。Web 消息操作快照将 B 的入队事件放在已结束轮次之间,验证分叉操作创建的子会话不包含 B 或其入队事件。
@@ -2,5 +2,5 @@
# 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/simplification/2026-07-28-remove-synthetic-log-only-turns.md
2026-07-28-remove-synthetic-log-only-turns.md: b35045d036dbb3ea18431b76cec2c41674de5607
2026-07-28-remove-synthetic-log-only-turns.zh.md: 3dbcd0d9b258f4ea9f4515ba009e75487a96c342
2026-07-28-remove-synthetic-log-only-turns.md: ae935d625f6aecf5773d3069ea5b73278a94f5c7
2026-07-28-remove-synthetic-log-only-turns.zh.md: c35431dcfbd863f147988ecbf2ccaedec6260bbc
@@ -20,7 +20,7 @@ Core session invariants continue to enforce core-owned execution relations: turn
The title service appends `session/title` directly after its existing service, revision, cancellation, and live-session checks. The bundled model helper appends its literal `session/title-llm-request` record before dispatch. Persistence admits both through the bounded `session/event` path and drains them at ordinary checkpoints and lifecycle teardown; neither append forces a flush merely because it is between turns. A fallback, auxiliary request record, or accepted provider title may therefore appear after `turn/end` and before the next `turn/start`. Manual compaction uses the same between-turn capability for a `compaction/* { turn: null }` bracket, but explicitly flushes the closed attempt because `/compact` promises durability before releasing queued prompt admission.
`SessionStore.fork()` may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and other plugin-owned log-only records in a default store fork while still rejecting a prefix cut through active execution. The [Session Controller fork decision](../bug-fix/2026-09-11-session-controller-fork-turn-cut.md) limits its completed-turn operation to the selected closing event.
`SessionStore.fork()` may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and other plugin-owned log-only records in a default store fork while still rejecting a prefix cut through active execution. The [Session Controller fork decision](../bug-fix/2026-09-11-session-controller-fork-turn-cut.md) stops its between-turn extension at the first inbox change.
The historical [universal turn-enclosure decision](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md) remains useful only as the reason the synthetic mechanism was introduced. The [context-injection decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) established the current meaning: one turn represents one model-loop execution. The [queued manual compaction decision](../feature/2026-07-30-queued-manual-compaction.md) applies that rule to a durable multi-event bracket and owns its marker and admission semantics.
@@ -20,7 +20,7 @@ Status: implemented
标题服务会在完成既有的服务状态、修订、取消和活跃会话检查后,直接追加 `session/title`。随附模型辅助函数会在发起调用前追加其字面量 `session/title-llm-request` 记录。持久化通过有界 `session/event` 路径接纳两者,并在常规检查点与生命周期结束时排空;二者都不会仅因为位于轮次之间就强制刷写。因此,回退标题、辅助请求记录或已接受的提供方标题可以出现在 `turn/end` 之后、下一个 `turn/start` 之前。手动压缩(compaction)利用同一项轮次间能力记录 `compaction/* { turn: null }` 标记对,但会显式刷写已闭合的尝试,因为 `/compact` 承诺在放行排队中的提示词前完成持久化。
`SessionStore.fork()` 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认的 store fork 会保留独立标题和其他插件所属的纯日志记录,同时仍拒绝在活跃执行过程中截断前缀。[Session Controller 分叉决策](../bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md)将其已结束轮次操作限制在选中的结束事件处。
`SessionStore.fork()` 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认的 store fork 会保留独立标题和其他插件所属的纯日志记录,同时仍拒绝在活跃执行过程中截断前缀。[Session Controller 分叉决策](../bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md)会在第一次 inbox 变更处停止轮次间扩展。
历史上的[通用轮次封闭决策](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md)如今只适合用于解释为何曾引入合成机制。[上下文注入决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md)确立了当前语义:一个轮次表示一次模型循环执行。[排队手动压缩决策](../feature/2026-07-30-queued-manual-compaction.zh.md)将该规则应用于持久多事件标记对,并拥有其标记与接纳语义。
@@ -2,5 +2,5 @@
# 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 packages/api/session-controller/README.md
README.md: 8429b2f73199946944ca5a4e634b27d5d2f91f75
README.zh.md: 3c20a2bfad7d29b764e9c47641e665721da8dce9
README.md: f71b4e7f281caa0d1471dca04de4accee8e4c4be
README.zh.md: daa4946e623a7cd9955744ce9477970462f339d7
+1 -1
View File
@@ -37,7 +37,7 @@ The Session object also carries local submission echoes: `session.beginSubmissio
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 history through the selected completed turn, including its `turn/end`. Events after that point, including queued input and model-setting changes, are excluded. An omitted or past-end anchor selects the last completed turn; an anchor inside an unfinished turn is rejected.
Fork copies history through the selected completed turn and retains following events until the next turn starts or the inbox changes. The stopping event and all later events are excluded; titles and model settings are inherited only when recorded before that stop. An omitted or past-end anchor selects the last completed turn; an anchor inside an unfinished turn is rejected.
<a id="session-media-references"></a>
+1 -1
View File
@@ -37,7 +37,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。
分叉复制截至选中已结束轮次的历史,并包含其 `turn/end`。该位置之后的事件均被排除,包括排队输入和模型设置变更。省略锚点或锚点超出日志末尾时,选择最后一个已结束轮次;位于未结束轮次内的锚点会被拒绝。
分叉复制截至选中已结束轮次的历史,并保留后续事件,直到下一轮开始或 inbox 发生变更。停止事件及其后的所有事件均被排除;标题和模型设置只有记录在该停止位置之前才会被继承。省略锚点或锚点超出日志末尾时,选择最后一个已结束轮次;位于未结束轮次内的锚点会被拒绝。
<a id="session-media-references"></a>
@@ -240,7 +240,12 @@ export class SessionCommandController {
{ sessionId: request.sessionId },
)
}
const cut = SessionLogOffset(boundary.seq + 1)
let cut = SessionLogOffset(boundary.seq + 1)
while (cut < source.events.length
&& source.events[cut]?.type !== 'turn/start'
&& source.events[cut]?.type !== 'agent/inbox/spliced') {
cut = SessionLogOffset(cut + 1)
}
let workspace: Workspace | undefined
try {
workspace = await this.forkWorkspace(source.header)
@@ -106,6 +106,9 @@ describe('sessions.fork', () => {
source.followup(message('A'))
await source.whenIdle()
const boundary = source.session.snapshotEvents().at(-1)!.seq
const title = source.session.append('session/title', {
title: 'Title after A', messageSeqs: [], source: { kind: 'user' },
})
if (anchor === 'message' || anchor === 'turn-end') {
source.followup(message('B'))
await source.whenIdle()
@@ -129,7 +132,9 @@ describe('sessions.fork', () => {
? item.content.flatMap(part => part.type === 'text' ? [part.text] : []) : [])
expect(userTexts).toEqual(['A', 'C'])
expect(adapter.requests.slice(requestCount)).toHaveLength(1)
expect(child.session.inheritedEventCount).toBe(boundary + 1)
expect(child.session.inheritedEventCount).toBe(title.seq + 1)
expect(child.session.snapshotEvents().slice(0, child.session.inheritedEventCount))
.toEqual(original.slice(0, title.seq + 1))
expect(source.session.snapshotEvents()).toEqual(original)
} finally {
await ctx.fiber.dispose()
@@ -317,10 +322,9 @@ describe('sessions.fork', () => {
await ctx.fiber.dispose()
})
it('inherits model selection through the completed turn and excludes later changes', async () => {
it.each(['next-turn', 'next-step', null] as const)('inherits model settings before %s inbox changes', async (target) => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-routed', 0)
source.append('turn/start', { turn: 1 })
const source = liveAgent(ctx, 'session-routed', 1)
source.append('request/header', {
header: {
config: {
@@ -331,16 +335,28 @@ describe('sessions.fork', () => {
},
reason: 'initial',
})
source.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
source.append('request/header', {
header: { config: { provider: 'later-provider', model: 'later-model' } },
reason: 'change',
})
const inherited = source.snapshotEvents()
if (target !== null) {
source.append('agent/inbox/spliced', {
target, start: 0,
inserted: [createUserMessage({
content: [{ type: 'text', text: 'next input' }], source: { kind: 'user' },
})],
})
source.append('session/title', {
title: 'Title after inbox insertion', messageSeqs: [], source: { kind: 'user' },
})
source.append('request/header', {
header: { config: { provider: 'later-provider', model: 'later-model' } },
reason: 'change',
})
}
const response = await remote(ctx).fork(request({ sessionId: source.id }))
expect(response.ok).toBe(true)
if (!response.ok) return
const child = ctx.agents.get(response.value.sessionId)
if (child === undefined) throw new Error('fork did not publish the child agent')
expect(child.session.snapshotEvents().slice(0, child.session.inheritedEventCount)).toEqual(inherited)
const assembly = await child.ctx.systemPrompt.assemble()
expect(assembly.variables).toMatchObject({
provider: 'inherited-provider',