fix(session): load pre-react-loop persisted sessions
This commit is contained in:
@@ -2963,13 +2963,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'ToolSchema',
|
||||
declaration: 'export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'TurnEndCancelCause',
|
||||
declaration: 'export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: \'legacy\';\n};',
|
||||
},
|
||||
{
|
||||
name: 'TurnEndReason',
|
||||
declaration: 'export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];',
|
||||
},
|
||||
{
|
||||
name: 'TurnEndReasonMap',
|
||||
declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason: AgentCancelCause;\n };\n blocked: {\n kind: \'blocked\';\n };\n error: {\n kind: \'error\';\n error: LlmFailure;\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}',
|
||||
declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: \'blocked\';\n };\n error: {\n kind: \'error\';\n error: LlmFailure;\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypertContribution',
|
||||
|
||||
@@ -77,7 +77,7 @@ function throwUnknown(value: unknown): never {
|
||||
}
|
||||
|
||||
describe('the session-persistence Agent Note: AgentLoop factory create/resume', () => {
|
||||
it('resumes a session persisted before messages gained identities', async () => {
|
||||
it('resumes a pre-react-loop session including pre-identity message events', async () => {
|
||||
const sessionId = SessionId('pre-identity-resume')
|
||||
const first = await persistentHarness(new MockAdapter([]))
|
||||
await first.ctx.sessionPersistence.create({
|
||||
@@ -86,7 +86,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
createdAt: 1,
|
||||
})
|
||||
await first.ctx.sessionPersistence.append(sessionId, [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{
|
||||
type: 'turn/start', seq: 0, time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
},
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
@@ -107,8 +110,19 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{ type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, step: 1, reason: { kind: 'completed' } } },
|
||||
{
|
||||
type: 'steering/message',
|
||||
seq: 4,
|
||||
time: 5,
|
||||
data: {
|
||||
turn: 1,
|
||||
content: [{ type: 'text', text: 'old steering' }],
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{ type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
] as unknown as SessionEvent[])
|
||||
await first.ctx.fiber.dispose()
|
||||
|
||||
@@ -120,14 +134,17 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
expect(handle.agent.session.deriveMessages()).toMatchObject([
|
||||
{ id: `legacy-message:${sessionId}:1`, role: 'user' },
|
||||
{ id: `legacy-message:${sessionId}:3`, role: 'assistant' },
|
||||
{ id: `legacy-message:${sessionId}:4`, role: 'user' },
|
||||
])
|
||||
expect(handle.agent.inbox.nextTurn).toEqual([])
|
||||
expect(handle.agent.inbox.nextStep).toEqual([])
|
||||
|
||||
handle.agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'new question' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
expect(handle.agent.session.deriveMessages()).toHaveLength(4)
|
||||
expect(handle.agent.session.deriveMessages()).toHaveLength(5)
|
||||
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
|
||||
@@ -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/core/session/README.md
|
||||
README.md: 95b2ec9a4c111e5c44d31e36b37f1a574776edef
|
||||
README.zh.md: f3ef4ffeb11b44d1bcfb76722f93c162b71415fd
|
||||
README.md: bad0cc33d6ffec6e9b8abbc86cf3a0c79fd23b74
|
||||
README.zh.md: e1bd5e6f448fb31e38642dc3df1e1ccf5977ef9d
|
||||
|
||||
@@ -77,7 +77,7 @@ Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own t
|
||||
|
||||
Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for turn endings. `turn/start` carries only the turn number; the following entered `user/message` batch records its input, while `llm/retry` records request recovery.
|
||||
|
||||
An interrupted live turn ends with `{ kind: 'aborted', reason: AgentCancelCause }`, preserving the typed cancellation cause in the durable transcript. A turn failure carries `{ kind: 'error', error }`; crash recovery alone synthesizes `{ kind: 'interrupted' }`.
|
||||
An interrupted live turn ends with `{ kind: 'aborted', reason: AgentCancelCause }`, preserving the typed cancellation cause in the durable transcript. Persistence imports the coarse aborted outcome from the supported older format as `{ kind: 'aborted', reason: { kind: 'legacy' } }`, because that record did not retain its caller. A turn failure carries `{ kind: 'error', error }`; crash recovery alone synthesizes `{ kind: 'interrupted' }`.
|
||||
|
||||
Every `SessionEvent` carries two optional top-level fields (structural metadata):
|
||||
|
||||
@@ -91,7 +91,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
### Extension points
|
||||
|
||||
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
|
||||
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model, assistant messages require provider/model provenance, and a coarse aborted outcome must contain only `{ kind: 'aborted' }` (legacy reason-bearing records are rejected). `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
|
||||
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model, and assistant messages require provider/model provenance. Persistence owns read compatibility before constructing this current-format seed. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
|
||||
- Compaction: `dsh-compact-basic` appends a `user/message` replacement for summary checkpoints, while `dsh-compact-tool-result-prune` appends a content-only `tool/result` replacement. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership, replacement validation, and `replaceGeneration`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
此包还定义 `TurnEndReasonMap`,即用于轮次结束、可合并扩展且以 `kind` 为标签的和类型。`turn/start` 只携带轮次编号;之后进入步骤的 `user/message` 批次记录其输入,`llm/retry` 则记录请求恢复。
|
||||
|
||||
被中断的实时轮次以 `{ kind: 'aborted', reason: AgentCancelCause }` 结束,在持久 transcript(文本记录)中保留类型化取消原因。轮次失败携带 `{ kind: 'error', error }`;只有崩溃恢复会合成 `{ kind: 'interrupted' }`。
|
||||
被中断的实时轮次以 `{ kind: 'aborted', reason: AgentCancelCause }` 结束,在持久 transcript(文本记录)中保留类型化取消原因。持久化会将受支持旧格式中的粗粒度中止结果导入为 `{ kind: 'aborted', reason: { kind: 'legacy' } }`,因为该记录没有保留调用方。轮次失败携带 `{ kind: 'error', error }`;只有崩溃恢复会合成 `{ kind: 'interrupted' }`。
|
||||
|
||||
每个 `SessionEvent` 都有两个可选顶层字段(结构元数据):
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
### 扩展点
|
||||
|
||||
- 持久化插件:订阅 `session/event`(延后写入),并在 `session/flush`(受等待)及 fiber dispose(资源释放)时排空。持久后端读取日志并重新加载到实时会话;这类后端会把元数据 seam(`SessionHeader`、`session.header`)与日志一同存储。
|
||||
- 回放/fork:`create(id, { seed })` 校验并冻结连续的当前格式日志,再重建 surface;请求头必须包含提供方/模型,assistant 消息必须包含提供方/模型溯源信息,而粗粒度中止结果必须只含 `{ kind: 'aborted' }`(带旧版原因的记录会被拒绝)。`fork(source, boundary?, childSessionId?)` 选择已完成轮次前缀并记录谱系。
|
||||
- 回放/fork:`create(id, { seed })` 校验并冻结连续的当前格式日志,再重建 surface;请求头必须包含提供方/模型,assistant 消息必须包含提供方/模型溯源信息。持久化层在构造该当前格式 seed 前负责读取兼容性处理。`fork(source, boundary?, childSessionId?)` 选择已完成轮次前缀并记录谱系。
|
||||
- 压缩:`dsh-compact-basic` 为摘要检查点追加一个替换用 `user/message`,而 `dsh-compact-tool-result-prune` 追加仅修改内容的 `tool/result` 替换。工具配对边界策略及其缓存归 [`dsh-compact` seam](../../compact/compact/README.md) 所有;此包拥有有序 surface 成员关系、替换校验与 `replaceGeneration`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -100,13 +100,16 @@ export type AgentCancelCause =
|
||||
| { readonly kind: 'hook'; readonly reason: string }
|
||||
| { readonly kind: 'disposed' }
|
||||
|
||||
/** Durable cancellation cause, including imports whose original coarse record carried no cause. */
|
||||
export type TurnEndCancelCause = AgentCancelCause | { readonly kind: 'legacy' }
|
||||
|
||||
/**
|
||||
* Why a turn ended. Merge-extensible sum type.
|
||||
*/
|
||||
export interface TurnEndReasonMap {
|
||||
completed: { kind: 'completed' }
|
||||
/** A cancellation request interrupted the live turn. */
|
||||
aborted: { kind: 'aborted'; reason: AgentCancelCause }
|
||||
aborted: { kind: 'aborted'; reason: TurnEndCancelCause }
|
||||
|
||||
blocked: { kind: 'blocked' }
|
||||
/**
|
||||
|
||||
@@ -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/session-persistence/session-persistence/README.md
|
||||
README.md: c72d53cac7c8b89cc73481e2094cd2ef57300aa5
|
||||
README.zh.md: 15a72fd45e17c3d71c5d0036c341ad92a1cbf1ff
|
||||
README.md: 7554d8e6804f880712f38f44efc2ed2f60a7dbfc
|
||||
README.zh.md: 0368e9d60c0217e5f030f4cd7880583d0f67aa94
|
||||
|
||||
@@ -13,9 +13,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
|
||||
| `locate(meta): SessionLocation \| undefined` | Resolve an absolute per-session artifact target without I/O or materialization. Backends without an independent local artifact return `undefined`. |
|
||||
| `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
|
||||
| `append(id, events): Promise<void>` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
|
||||
| `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log whose events are detached and validated and whose identified messages are deeply frozen. The coordinator upgrades the four pre-identity message event shapes into current wrappers in the returned snapshot; all other obsolete or malformed shapes still reject. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and unknown `version` reject. |
|
||||
| `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log whose events are detached and validated and whose identified messages are deeply frozen. The coordinator upgrades the supported same-version message and pre-react-loop event shapes into the current read snapshot; all other obsolete or malformed shapes still reject. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and unknown `version` reject. |
|
||||
| `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix with upgraded, validated, deeply frozen identified messages, without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. |
|
||||
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The read-from-seq primitive: return the header plus the valid stored events with `seq >= fromSeq`, detached and non-mutating like `inspect` (no truncation, no closers, no coordinator state). A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix; sequential backends (JSONL) still parse the whole artifact and skip forward — the primitive bounds what is returned and refolded, not every backend's physical read. Intended for checkpoint consumers (e.g. the persisted projection cache) that fold only the tail past a watermark. |
|
||||
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The read-from-seq primitive: return the header plus the valid stored events with `seq >= fromSeq`, detached and non-mutating like `inspect` (no truncation, no closers, no coordinator state). A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless a legacy event in that suffix requires prefix context for normalization; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers (e.g. the persisted projection cache) that fold only the tail past a watermark. |
|
||||
| `list(signal?): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. |
|
||||
| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. |
|
||||
|
||||
@@ -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`, 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.
|
||||
Backend reads normalize the exact supported same-version shapes before current-shape validation. Pre-identity messages receive the deterministic id `legacy-message:<session-id>:<event-seq>`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` gains its last entered step while its terminal reason maps without inventing unavailable cancellation provenance. The coordinator uses the same normalized view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current shape. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, 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.
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
| `locate(meta): SessionLocation \| undefined` | 在不执行 I/O 或实体化的情况下解析绝对的每会话产物目标。没有独立本地产物的后端返回 `undefined`。 |
|
||||
| `create(meta): Promise<void>` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 |
|
||||
| `append(id, events): Promise<void>` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 |
|
||||
| `load(id): Promise<{ meta; events }>` | 返回已存储 header 和平衡、连续的日志,其中事件已脱离并验证,带标识的消息已深度冻结。协调器会在返回快照中,将消息标识机制引入前的四种消息事件形状升级为当前包装层;其余过时或格式错误的形状仍会被拒绝。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件关闭它。只丢弃撕裂尾部碎片;已提交损坏和未知 `version` 会被拒绝。 |
|
||||
| `load(id): Promise<{ meta; events }>` | 返回已存储 header 和平衡、连续的日志,其中事件已脱离并验证,带标识的消息已深度冻结。协调器会将受支持的同版本消息形状与 react-loop 重构前的事件形状升级为当前读取快照;其余过时或格式错误的形状仍会被拒绝。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件关闭它。只丢弃撕裂尾部碎片;已提交损坏和未知 `version` 会被拒绝。 |
|
||||
| `inspect(id, signal?): Promise<{ meta; events }>` | 返回脱离的有效已存储前缀,其中带标识的消息已经升级、验证并深度冻结;不截断撕裂尾部、合成恢复 closer 或发布协调器状态。它与同 id 写入串行化;可选信号会迅速拒绝已排队调用方,阻止该后端读取启动,并取消活动后端读取工作。用于绝不应恢复日志的读模型和其他观察者。 |
|
||||
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | read-from-seq 原语:返回 header 和 `seq >= fromSeq` 的有效已存储事件,与 `inspect` 同样脱离且非变更(不截断、不合成 closer、不发布协调器状态)。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀;顺序后端(JSONL)仍解析整个产物并向前跳过——原语约束的是返回和重折叠的量,不是每个后端的物理读取。用于从水位续折尾部的 checkpoint 消费者(例如持久投影缓存)。 |
|
||||
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | read-from-seq 原语:返回 header 和 `seq >= fromSeq` 的有效已存储事件,与 `inspect` 同样脱离且非变更(不截断、不合成 closer、不发布协调器状态)。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非后缀中的旧版事件需要前缀上下文才能完成规范化;顺序后端(JSONL)会解析整个产物并向前跳过。用于从水位续折尾部的 checkpoint 消费方(例如持久投影缓存)。 |
|
||||
| `list(signal?): Promise<SessionHeader[]>` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 |
|
||||
| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | 返回轻量元数据和不透明品牌化每日志修订,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端在拒绝前结算已启动列表工作,使已等待调用完全停稳。 |
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
崩溃修复只适用于冷状态。对于实时 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时将其与协调器已存储 header 一起返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。冷 load 在后端读取和修复写入期间保留 id,因此同 id 实时 `Session` 的并发发布会拒绝并回滚。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。
|
||||
|
||||
后端读取会在当前形状验证前,规范化消息标识机制引入前的 `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 迁移承诺。
|
||||
后端读取会在当前形状验证前,规范化明确受支持的同版本形状。消息标识机制引入前的消息会获得确定性的 id `legacy-message:<session-id>:<event-seq>`;工具结果的内容替换会继承其目标导入后的 id。react-loop 重构前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会补上最后进入的步骤,并在不虚构无法获得的取消来源的前提下映射终止原因。协调器对 `load`、`inspect`、`readFrom`、无 owner 状态的认领和 HMR 前缀接管使用同一份规范化视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前形状。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 重构前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。
|
||||
|
||||
实时会话发出 `session/disposed` 时,协调器等待其 controller,串行化最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在实时会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。
|
||||
|
||||
|
||||
@@ -69,7 +69,10 @@ export interface PersistenceBackend<TornMarker = unknown> {
|
||||
* omit it and the coordinator falls back to {@link loadStored} plus a
|
||||
* forward skip. Non-mutating (no truncation, no closers). Validation of the
|
||||
* region strictly below `fromSeq` is limited to seq contiguity — the
|
||||
* service contract scopes this read to the suffix.
|
||||
* service contract scopes this read to the suffix — unless that suffix
|
||||
* contains a supported legacy shape whose normalization needs earlier
|
||||
* step or message-identity facts, in which case the coordinator falls back
|
||||
* to the complete stored prefix.
|
||||
* @param id - persisted session id to resolve.
|
||||
* @param fromSeq - first event seq to include (non-negative safe integer,
|
||||
* validated by the coordinator before this hook runs).
|
||||
@@ -180,6 +183,17 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Whether a record contains every required key and no key outside the optional extension set. */
|
||||
function hasOnlyKeys(
|
||||
record: Record<string, unknown>,
|
||||
required: readonly string[],
|
||||
optional: readonly string[] = [],
|
||||
): boolean {
|
||||
const allowed = [...required, ...optional]
|
||||
return Object.keys(record).every(key => allowed.includes(key))
|
||||
&& required.every(key => Object.hasOwn(record, key))
|
||||
}
|
||||
|
||||
type PersistedMessageId = SessionEvent<'user/message'>['data']['id']
|
||||
|
||||
/** Mint the stable import identity for a message persisted before identities existed. */
|
||||
@@ -195,6 +209,137 @@ function replacementStart(event: SessionEvent): number | undefined {
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Whether one suffix event needs facts available only from the preceding stored prefix. */
|
||||
function needsLegacyPrefix(event: SessionEvent): boolean {
|
||||
const data = asRecord(event.data)
|
||||
const legacySteeringType: string = 'steering/message'
|
||||
if (event.type === legacySteeringType) return true
|
||||
if (event.type === 'turn/end' && data !== undefined && !Object.hasOwn(data, 'step')) return true
|
||||
if (data === undefined) return false
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
return !Object.hasOwn(data, 'id') && Object.hasOwn(data, 'content')
|
||||
case 'assistant/message':
|
||||
return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'content')
|
||||
case 'tool/result':
|
||||
return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'callId')
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Upgrade the removed steering surface event into its current user-message equivalent. */
|
||||
function migrateLegacySteeringEvent(event: SessionEvent, id: SessionId): SessionEvent {
|
||||
const legacyType: string = 'steering/message'
|
||||
if (event.type !== legacyType) return event
|
||||
const data = asRecord(event.data)
|
||||
if (data === undefined) {
|
||||
throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`)
|
||||
}
|
||||
const wrapped = asRecord(data['message'])
|
||||
if (wrapped !== undefined && Number.isSafeInteger(data['turn'])
|
||||
&& hasOnlyKeys(data, ['turn', 'message'])) {
|
||||
return { ...event, type: 'user/message', data: wrapped } as SessionEvent
|
||||
}
|
||||
if (!Number.isSafeInteger(data['turn']) || !hasOnlyKeys(data, ['turn', 'content', 'source'])) {
|
||||
throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`)
|
||||
}
|
||||
const { turn: _turn, ...message } = data
|
||||
return {
|
||||
...event,
|
||||
type: 'user/message',
|
||||
data: {
|
||||
...message,
|
||||
id: legacyMessageId(id, event.seq),
|
||||
role: 'user',
|
||||
},
|
||||
} as SessionEvent
|
||||
}
|
||||
|
||||
/** Remove the obsolete trigger after verifying the complete old turn-start envelope. */
|
||||
function migrateLegacyTurnStartEvent(event: SessionEvent, id: SessionId): SessionEvent {
|
||||
if (event.type !== 'turn/start') return event
|
||||
const data = asRecord(event.data)
|
||||
if (data === undefined || !Object.hasOwn(data, 'trigger')) return event
|
||||
const trigger = asRecord(data['trigger'])
|
||||
if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1
|
||||
|| !hasOnlyKeys(data, ['turn', 'trigger'])
|
||||
|| trigger === undefined || typeof trigger['kind'] !== 'string' || trigger['kind'].length === 0) {
|
||||
throw new Error(`session "${id}" contains malformed pre-react-loop turn/start at seq ${event.seq}`)
|
||||
}
|
||||
return { ...event, data: { turn: data['turn'] } } as SessionEvent
|
||||
}
|
||||
|
||||
/** Upgrade the turn boundary emitted immediately before the loop refactor. */
|
||||
function migrateLegacyTurnEndEvent(event: SessionEvent, id: SessionId, lastStep: number): SessionEvent {
|
||||
if (event.type !== 'turn/end') return event
|
||||
const data = asRecord(event.data)
|
||||
if (data === undefined || Object.hasOwn(data, 'step')) return event
|
||||
const malformed = (): never => {
|
||||
throw new Error(`session "${id}" contains malformed pre-react-loop turn/end at seq ${event.seq}`)
|
||||
}
|
||||
const reason = asRecord(data['reason'])
|
||||
if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1
|
||||
|| !hasOnlyKeys(data, ['turn', 'reason'])
|
||||
|| reason === undefined || typeof reason['kind'] !== 'string') return malformed()
|
||||
|
||||
let currentReason: Record<string, unknown>
|
||||
switch (reason['kind']) {
|
||||
case 'completed':
|
||||
case 'max-tokens':
|
||||
case 'interrupted':
|
||||
if (!hasOnlyKeys(reason, ['kind'])) return malformed()
|
||||
currentReason = { kind: reason['kind'] }
|
||||
break
|
||||
case 'aborted':
|
||||
if (!hasOnlyKeys(reason, ['kind'])) return malformed()
|
||||
currentReason = { kind: 'aborted', reason: { kind: 'legacy' } }
|
||||
break
|
||||
case 'disposed':
|
||||
if (!hasOnlyKeys(reason, ['kind'])) return malformed()
|
||||
currentReason = { kind: 'aborted', reason: { kind: 'disposed' } }
|
||||
break
|
||||
case 'error': {
|
||||
if (!Number.isSafeInteger(reason['step']) || (reason['step'] as number) < 0) return malformed()
|
||||
const failure = asRecord(reason['failure'])
|
||||
if (failure !== undefined && hasOnlyKeys(reason, ['kind', 'step', 'failure'])
|
||||
&& hasOnlyKeys(failure, ['message', 'code'], ['status', 'providerRetryAfterMs', 'requestId'])
|
||||
&& typeof failure['message'] === 'string' && typeof failure['code'] === 'string'
|
||||
&& (failure['status'] === undefined || typeof failure['status'] === 'number')
|
||||
&& (failure['providerRetryAfterMs'] === undefined || typeof failure['providerRetryAfterMs'] === 'number')
|
||||
&& (failure['requestId'] === undefined || typeof failure['requestId'] === 'string')) {
|
||||
currentReason = { kind: 'error', error: failure }
|
||||
break
|
||||
}
|
||||
const messageKeys = reason['code'] === undefined
|
||||
? ['kind', 'step', 'message']
|
||||
: ['kind', 'step', 'message', 'code']
|
||||
if (!hasOnlyKeys(reason, messageKeys)
|
||||
|| typeof reason['message'] !== 'string'
|
||||
|| (reason['code'] !== undefined && typeof reason['code'] !== 'string')) return malformed()
|
||||
currentReason = {
|
||||
kind: 'error',
|
||||
error: {
|
||||
message: reason['message'],
|
||||
code: typeof reason['code'] === 'string' ? reason['code'] : 'UNKNOWN',
|
||||
},
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
return malformed()
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
data: {
|
||||
...data,
|
||||
step: lastStep,
|
||||
reason: currentReason,
|
||||
},
|
||||
} as SessionEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade one pre-identity message event into the current wrapper shape.
|
||||
* Current-looking malformed events remain untouched so validation rejects them
|
||||
@@ -286,8 +431,18 @@ function eventMessageId(event: SessionEvent): PersistedMessageId | undefined {
|
||||
function snapshotStoredEvents(events: readonly SessionEvent[], id: SessionId): SessionEvent[] {
|
||||
assertSupportedEvents(events, id)
|
||||
const messageIds = new Map<number, PersistedMessageId>()
|
||||
const lastSteps = new Map<number, number>()
|
||||
return events.map((event) => {
|
||||
const snapshot = snapshotSessionEvent(migrateLegacyMessageEvent(event, id, messageIds))
|
||||
const stepData = event.type === 'step/end' ? asRecord(event.data) : undefined
|
||||
if (typeof stepData?.['turn'] === 'number' && typeof stepData['step'] === 'number') {
|
||||
lastSteps.set(stepData['turn'], stepData['step'])
|
||||
}
|
||||
const turnData = event.type === 'turn/end' ? asRecord(event.data) : undefined
|
||||
const lastStep = typeof turnData?.['turn'] === 'number' ? lastSteps.get(turnData['turn']) ?? 0 : 0
|
||||
const migratedStart = migrateLegacyTurnStartEvent(event, id)
|
||||
const migratedTurn = migrateLegacyTurnEndEvent(migratedStart, id, lastStep)
|
||||
const migratedSteering = migrateLegacySteeringEvent(migratedTurn, id)
|
||||
const snapshot = snapshotSessionEvent(migrateLegacyMessageEvent(migratedSteering, id, messageIds))
|
||||
const messageId = eventMessageId(snapshot)
|
||||
if (messageId !== undefined) messageIds.set(snapshot.seq, messageId)
|
||||
return snapshot
|
||||
@@ -505,8 +660,11 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
if (suffix === undefined) throw new Error(`session "${id}" not found`)
|
||||
this.assertStoredId(id, suffix.meta)
|
||||
this.assertVersion(suffix.meta)
|
||||
assertSupportedEvents(suffix.events, id)
|
||||
return { meta: structuredClone(suffix.meta), events: structuredClone(suffix.events) }
|
||||
if (suffix.events.some(needsLegacyPrefix)) {
|
||||
const whole = await this.inspectCore(id, signal)
|
||||
return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) }
|
||||
}
|
||||
return { meta: structuredClone(suffix.meta), events: snapshotStoredEvents(suffix.events, id) }
|
||||
}
|
||||
const whole = await this.inspectCore(id, signal)
|
||||
// Sequential fallback: contiguous seqs from 0 make the suffix an index slice.
|
||||
|
||||
@@ -108,6 +108,67 @@ function legacyMessageLog(): SessionEvent[] {
|
||||
] as unknown as SessionEvent[]
|
||||
}
|
||||
|
||||
/** A complete log in the durable event vocabulary of the react-loop refactor base. */
|
||||
function preReactLoopLog(): SessionEvent[] {
|
||||
const prompt = createUserMessage({
|
||||
content: [{ type: 'text', text: 'old prompt' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
const steering = createUserMessage({
|
||||
content: [{ type: 'text', text: 'old steering' }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
return [
|
||||
{
|
||||
type: 'turn/start', seq: 0, time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
},
|
||||
{ type: 'user/message', seq: 1, time: 2, data: prompt, surfaceOp: 'append' },
|
||||
{ type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
|
||||
{
|
||||
type: 'steering/message', seq: 3, time: 4,
|
||||
data: { turn: 1, message: steering },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{ type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'retry' } } },
|
||||
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
|
||||
{ type: 'step/end', seq: 8, time: 9, data: { turn: 2, step: 1 } },
|
||||
{
|
||||
type: 'turn/end', seq: 9, time: 10,
|
||||
data: {
|
||||
turn: 2,
|
||||
reason: {
|
||||
kind: 'error',
|
||||
step: 1,
|
||||
failure: { message: 'old provider failure', code: 'SERVER' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'turn/start', seq: 10, time: 11,
|
||||
data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
},
|
||||
{ type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'aborted' } } },
|
||||
{
|
||||
type: 'turn/start', seq: 12, time: 13,
|
||||
data: { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
},
|
||||
{ type: 'turn/end', seq: 13, time: 14, data: { turn: 4, reason: { kind: 'disposed' } } },
|
||||
{
|
||||
type: 'turn/start', seq: 14, time: 15,
|
||||
data: { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
},
|
||||
{ type: 'step/start', seq: 15, time: 16, data: { turn: 5, step: 1 } },
|
||||
{ type: 'step/end', seq: 16, time: 17, data: { turn: 5, step: 1 } },
|
||||
{
|
||||
type: 'turn/end', seq: 17, time: 18,
|
||||
data: { turn: 5, reason: { kind: 'error', step: 1, message: 'old thrown value' } },
|
||||
},
|
||||
] as unknown as SessionEvent[]
|
||||
}
|
||||
|
||||
/** A live session created inside its OWN fiber, so it survives a backend reload. */
|
||||
async function liveSessionInFiber(
|
||||
ctx: Context, id: string, cwd: string | undefined,
|
||||
@@ -367,6 +428,69 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
`legacy-message:${id}:5`,
|
||||
])
|
||||
}
|
||||
|
||||
const replacementSuffix = await ctx.sessionPersistence.readFrom(id, 6)
|
||||
expect(replacementSuffix.events[0]).toMatchObject({
|
||||
type: 'tool/result',
|
||||
seq: 6,
|
||||
data: { message: { id: `legacy-message:${id}:5` } },
|
||||
})
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('loads pre-react-loop session logs into resumable current sessions', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
const id = SessionId('pre-react-loop-load')
|
||||
const log = preReactLoopLog()
|
||||
const legacySteering = log[3] as unknown as { data: { message: { id: string } } }
|
||||
await ctx.sessionPersistence.create(meta(id, WORK))
|
||||
await ctx.sessionPersistence.append(id, log)
|
||||
|
||||
const snapshots = [
|
||||
await ctx.sessionPersistence.inspect(id),
|
||||
await ctx.sessionPersistence.readFrom(id, 0),
|
||||
await ctx.sessionPersistence.load(id),
|
||||
]
|
||||
for (const snapshot of snapshots) {
|
||||
expect(snapshot.events.some(event => (event.type as string) === 'steering/message')).toBe(false)
|
||||
expect(snapshot.events.filter(event => event.type === 'turn/start').map(event => event.data))
|
||||
.toEqual([{ turn: 1 }, { turn: 2 }, { turn: 3 }, { turn: 4 }, { turn: 5 }])
|
||||
expect(snapshot.events.filter(event => event.type === 'turn/end').map(event => event.data)).toEqual([
|
||||
{ turn: 1, step: 1, reason: { kind: 'completed' } },
|
||||
{
|
||||
turn: 2,
|
||||
step: 1,
|
||||
reason: { kind: 'error', error: { message: 'old provider failure', code: 'SERVER' } },
|
||||
},
|
||||
{ turn: 3, step: 0, reason: { kind: 'aborted', reason: { kind: 'legacy' } } },
|
||||
{ turn: 4, step: 0, reason: { kind: 'aborted', reason: { kind: 'disposed' } } },
|
||||
{
|
||||
turn: 5,
|
||||
step: 1,
|
||||
reason: { kind: 'error', error: { message: 'old thrown value', code: 'UNKNOWN' } },
|
||||
},
|
||||
])
|
||||
|
||||
const resumed = new Session(id, snapshot.events, snapshot.meta)
|
||||
expect(resumed.deriveMessages().map(message => message.content)).toEqual([
|
||||
[{ type: 'text', text: 'old prompt' }],
|
||||
[{ type: 'text', text: 'old steering' }],
|
||||
])
|
||||
}
|
||||
|
||||
const suffix = await ctx.sessionPersistence.readFrom(id, 3)
|
||||
expect(suffix.events[0]).toMatchObject({
|
||||
type: 'user/message',
|
||||
seq: 3,
|
||||
data: { id: legacySteering.data.message.id },
|
||||
})
|
||||
expect(suffix.events.filter(event => event.type === 'turn/end').map(event => event.data.step))
|
||||
.toEqual([1, 1, 0, 0, 1])
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
@@ -397,6 +521,40 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
await expect(ctx.sessionPersistence.load(id))
|
||||
.rejects.toThrow('message must have role "user"')
|
||||
|
||||
const malformedLegacy: { id: string; event: SessionEvent; message: string }[] = [
|
||||
{
|
||||
id: 'invalid-old-turn-start',
|
||||
event: {
|
||||
type: 'turn/start', seq: 0, time: 1,
|
||||
data: { turn: 1, trigger: null },
|
||||
} as unknown as SessionEvent,
|
||||
message: 'malformed pre-react-loop turn/start',
|
||||
},
|
||||
{
|
||||
id: 'invalid-old-steering',
|
||||
event: {
|
||||
type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append',
|
||||
data: { turn: 1, content: [], source: { kind: 'user' }, extra: true },
|
||||
} as unknown as SessionEvent,
|
||||
message: 'malformed pre-react-loop steering/message',
|
||||
},
|
||||
{
|
||||
id: 'invalid-old-turn-end',
|
||||
event: {
|
||||
type: 'turn/end', seq: 0, time: 1,
|
||||
data: { turn: 1, reason: { kind: 'completed', extra: true } },
|
||||
} as unknown as SessionEvent,
|
||||
message: 'malformed pre-react-loop turn/end',
|
||||
},
|
||||
]
|
||||
for (const malformed of malformedLegacy) {
|
||||
const malformedId = SessionId(malformed.id)
|
||||
await ctx.sessionPersistence.create(meta(malformedId, WORK))
|
||||
await ctx.sessionPersistence.append(malformedId, [malformed.event])
|
||||
await expect(ctx.sessionPersistence.inspect(malformedId)).rejects.toThrow(malformed.message)
|
||||
await expect(ctx.sessionPersistence.readFrom(malformedId, 0)).rejects.toThrow(malformed.message)
|
||||
}
|
||||
|
||||
for (const type of ['tool/result'] as const) {
|
||||
const malformedId = SessionId(`invalid-${type}`)
|
||||
await ctx.sessionPersistence.create(meta(malformedId, WORK))
|
||||
|
||||
Reference in New Issue
Block a user