refactor(agent-loop): drop the steering/message session event

Steer, inject, and followup now land as durable user/message events on the
session surface; the steering/message event type and its ConversationNode
kind are removed from the client projection. Update tests, docs, generated
catalogs, and agent notes to match, and align the steering e2e fixture and
prompt inventory assertions with the durable user/message landing.
This commit is contained in:
_Kerman
2026-08-03 16:04:26 +08:00
parent f4a2e0d10a
commit cb2f01f48c
86 changed files with 243 additions and 458 deletions

View File

@@ -34,7 +34,7 @@ Each `session/event` copies its event into the session controller and starts an
Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. A cold load reserves its id across backend reads and repair writes, so concurrent publication of a same-id live `Session` rejects and rolls back. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn.
Backend reads normalize pre-identity `user/message`, `assistant/message`, `tool/result`, and `steering/message` payloads before current-shape validation. Each imported message receives the deterministic id `legacy-message:<session-id>:<event-seq>`; a tool-result content replacement inherits its target's imported id. The coordinator uses the same normalized view for `load`, `inspect`, ownerless-state claims, and HMR prefix adoption, so resumed sessions can append current events without a false prefix collision. Storage remains append-only: the read does not rewrite old records, and every later append uses the current shape. This is the narrow import exception from the [pre-identity message recovery decision](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md), not a general v0 migration promise.
Backend reads normalize pre-identity `user/message`, `assistant/message`, and `tool/result` payloads before current-shape validation. Each imported message receives the deterministic id `legacy-message:<session-id>:<event-seq>`; a tool-result content replacement inherits its target's imported id. The coordinator uses the same normalized view for `load`, `inspect`, ownerless-state claims, and HMR prefix adoption, so resumed sessions can append current events without a false prefix collision. Storage remains append-only: the read does not rewrite old records, and every later append uses the current shape. This is the narrow import exception from the [pre-identity message recovery decision](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md), not a general v0 migration promise.
When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle.

View File

@@ -34,7 +34,7 @@
崩溃修复只适用于冷状态。对于实时 id`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时将其与协调器已存储 header 一起返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。冷 load 在后端读取和修复写入期间保留 id因此同 id 实时 `Session` 的并发发布会拒绝并回滚。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。
后端读取会在当前形状验证前,规范化消息标识机制引入前的 `user/message``assistant/message``tool/result` 以及 steering中途引导对应的 `steering/message` 载荷。每条导入消息都会获得确定性的 id `legacy-message:<session-id>:<event-seq>`;工具结果的内容替换会继承其目标导入后的 id。协调器对 `load``inspect`、无 owner 状态的认领和 HMR 前缀接管使用同一份规范化视图,因此恢复后的会话可以追加当前事件,不会被误判为发生前缀冲突。存储仍然仅追加:读取不会重写旧记录,此后追加的每个事件都使用当前形状。这是[消息标识机制引入前的消息恢复决策](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。
后端读取会在当前形状验证前,规范化消息标识机制引入前的 `user/message``assistant/message``tool/result` 载荷。每条导入消息都会获得确定性的 id `legacy-message:<session-id>:<event-seq>`;工具结果的内容替换会继承其目标导入后的 id。协调器对 `load``inspect`、无 owner 状态的认领和 HMR 前缀接管使用同一份规范化视图,因此恢复后的会话可以追加当前事件,不会被误判为发生前缀冲突。存储仍然仅追加:读取不会重写旧记录,此后追加的每个事件都使用当前形状。这是[消息标识机制引入前的消息恢复决策](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。
实时会话发出 `session/disposed` 时,协调器等待其 controller串行化最终 drain然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在实时会话 map 中使后端拆卸可重试。后端拆卸先停止事件接纳flush 每个剩余 controller等待每 id 操作,最后才关闭存储句柄。

View File

@@ -270,23 +270,6 @@ function migrateLegacyMessageEvent(
},
} as SessionEvent
}
case 'steering/message': {
if (Object.hasOwn(data, 'message')
|| !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'source')) return event
const { content, source, ...eventData } = data
return {
...event,
data: {
...eventData,
message: {
id: legacyMessageId(id, event.seq),
role: 'user',
content,
source,
},
},
} as SessionEvent
}
default:
return event
}

View File

@@ -89,20 +89,9 @@ function legacyMessageLog(): SessionEvent[] {
sourceEventSeqs: [4],
surfaceOp: 'append',
},
{
type: 'steering/message',
seq: 6,
time: 7,
data: {
turn: 1,
content: [{ type: 'text', text: 'continue' }],
source: { kind: 'plugin', plugin: 'test' },
},
surfaceOp: 'append',
},
{
type: 'tool/result',
seq: 7,
seq: 6,
time: 8,
data: {
turn: 1,
@@ -114,8 +103,8 @@ function legacyMessageLog(): SessionEvent[] {
sourceEventSeqs: [5],
surfaceOp: { op: 'replace', start: 5, end: 5 },
},
{ type: 'step/end', seq: 8, time: 9, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 9, time: 10, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
{ type: 'step/end', seq: 7, time: 9, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 8, time: 10, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
] as unknown as SessionEvent[]
}
@@ -357,18 +346,16 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await ctx.sessionPersistence.inspect(id),
await ctx.sessionPersistence.load(id),
]) {
const messages = snapshot.events.flatMap((event) => {
if (event.type === 'user/message') return [event.data]
if (event.type === 'assistant/message'
|| event.type === 'tool/result'
|| event.type === 'steering/message') return [event.data.message]
return []
})
const messages: { id: string }[] = []
for (const event of snapshot.events) {
if (event.type === 'user/message') messages.push(event.data)
else if (event.type === 'assistant/message'
|| event.type === 'tool/result') messages.push(event.data.message)
}
expect(messages.map(message => message.id)).toEqual([
`legacy-message:${id}:1`,
`legacy-message:${id}:3`,
`legacy-message:${id}:5`,
`legacy-message:${id}:6`,
`legacy-message:${id}:5`,
])
expect(messages.every(message => Object.isFrozen(message))).toBe(true)
@@ -378,7 +365,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
`legacy-message:${id}:1`,
`legacy-message:${id}:3`,
`legacy-message:${id}:5`,
`legacy-message:${id}:6`,
])
}
} finally {
@@ -411,7 +397,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await expect(ctx.sessionPersistence.load(id))
.rejects.toThrow('message must have role "user"')
for (const type of ['tool/result', 'steering/message'] as const) {
for (const type of ['tool/result'] as const) {
const malformedId = SessionId(`invalid-${type}`)
await ctx.sessionPersistence.create(meta(malformedId, WORK))
await ctx.sessionPersistence.append(malformedId, [{