diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml new file mode 100644 index 0000000000..c2a49d6b53 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-08-04-load-pre-react-loop-sessions.md +2026-08-04-load-pre-react-loop-sessions.md: da204822251ef646b982683e74413a001a19873a +2026-08-04-load-pre-react-loop-sessions.zh.md: 1c2818fd3afbefb3af91f53514b9cfc2baa01739 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md new file mode 100644 index 0000000000..da20482225 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md @@ -0,0 +1,40 @@ +# Agent Note: Load sessions from the pre-react-loop format + +Status: implemented + +English | [中文](2026-08-04-load-pre-react-loop-sessions.zh.md) + +## Problem + +The react-loop simplification changed durable events while retaining `SESSION_FORMAT_VERSION` 0. Stored sessions from the change's base contain `steering/message`, a `turn/start.trigger`, and `turn/end` records without the current top-level `step`; their terminal reasons also use coarse `aborted`, separate `disposed`, and two older error payloads. Current surface and turn invariants cannot replay those records directly. + +The new durable inbox is not part of this compatibility problem. The base emitted process-local inbox notifications but no `agent/inbox/*` session events, so replaying old history as pending work would resurrect already claimed or discarded prompts. + +## Decision + +`PersistenceCoordinator` recognizes the exact pre-react-loop shapes after backend decoding and projects them into the current read view. It removes the obsolete `turn/start.trigger`, converts `steering/message` to the same identified `user/message`, adds the last entered step to `turn/end`, maps old failure facts into the current structured error, folds `disposed` into an aborted turn with the `disposed` cause, and represents coarse aborted records with the persistence-only `{ kind: 'legacy' }` cause because their caller is unavailable. + +The coordinator applies the projection to `load`, `inspect`, adoption, HMR prefix comparison, and `readFrom`. A seek-capable `readFrom` normally reads only its suffix; when that suffix contains a legacy event needing an earlier step or replacement identity, the coordinator loads and normalizes the complete prefix before returning the requested seq range. + +The importer does not synthesize inbox splices. A resumed pre-react-loop agent begins with empty pending lists, matching the base runtime's inability to persist pending inbox work. The stored artifact remains append-only and later events use the current format. + +## Alternatives considered + +**Treat the same-version records as unsupported.** This follows the pre-release default but strands sessions produced by the PR base even though the removed steering content, turn step, and terminal facts have complete mappings. + +**Replay old inbox notifications into durable splices.** Those notifications were not session events and do not provide a trustworthy pending-state snapshot. Inferring insertions without every claim and discard would re-run consumed work. + +**Assign coarse aborted records to an existing caller.** Mapping them to `user`, `parent`, or `hook` would manufacture provenance. A dedicated `legacy` cause keeps the stop classification without making a false audit claim. + +**Rewrite stored JSONL and SQLite records.** A rewrite would violate the append-only contract and require backend-specific atomic migration machinery for a read compatibility boundary. + +## Consequences + +Sessions written in the refactor's base format resume through the current AgentLoop with their steering content, turn boundaries, error facts, and stop classification intact. The shared coordinator contract covers in-memory, JSONL, and SQLite `load`/`inspect`/`readFrom`, including the SQLite suffix fallback; an assembled JSONL Agent resume verifies that the historical transcript is visible while both new inbox lists start empty. + +This exception supports the base format, not intermediate formats produced during development of the refactor. In particular, it defines no migration for earlier experimental `agent/inbox/spliced` payloads. Exact-shape recognition keeps malformed current-looking records on their rejection path instead of guessing them into validity. + +## Related + +- [Load sessions persisted before message identity](2026-07-28-load-pre-identity-session-messages.md) — owns deterministic identities and the general read-only import boundary for another same-version format change. +- [Session persistence as an abstract service](../architecture/2026-06-14-session-persistence.md) — owns append-only backend storage and resume. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md new file mode 100644 index 0000000000..1c2818fd3a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.zh.md @@ -0,0 +1,40 @@ +# Agent Note: 加载 react-loop 重构前格式的会话 + +Status: implemented + +[English](2026-08-04-load-pre-react-loop-sessions.md) | 中文 + +## 问题 + +react-loop 简化在保持 `SESSION_FORMAT_VERSION` 为 0 的同时更改了持久事件。该变更基线所存储的会话包含 steering(中途引导)事件 `steering/message`、`turn/start.trigger`,以及缺少当前顶层 `step` 的 `turn/end` 记录;其终止原因还使用粗粒度 `aborted`、独立的 `disposed` 和两种旧版错误载荷。当前 surface 和轮次不变量无法直接回放这些记录。 + +新的持久 inbox 不属于此兼容性问题。该基线会发出进程本地 inbox 通知,但不会产生 `agent/inbox/*` 会话事件,因此将旧历史回放为待处理工作会让已经领取或丢弃的提示词再次执行。 + +## 决策 + +`PersistenceCoordinator` 会在后端解码后识别 react-loop 重构前的确切形状,并将其投影为当前读取视图。它移除已废弃的 `turn/start.trigger`,把 `steering/message` 转换为同一条带标识的 `user/message`,为 `turn/end` 补上最后进入的步骤,将旧版失败事实映射为当前结构化错误,把 `disposed` 折叠为带 `disposed` 原因的已中止轮次,并用仅供持久化导入使用的 `{ kind: 'legacy' }` 原因表示粗粒度中止记录,因为无法获得其调用方。 + +协调器会把该投影应用于 `load`、`inspect`、接管、HMR 前缀比较和 `readFrom`。可寻址的 `readFrom` 通常只读取后缀;如果后缀包含需要更早步骤或替换标识的旧版事件,协调器会先加载并规范化完整前缀,再返回所请求的 seq 范围。 + +导入器不会合成 inbox splice。恢复后的 react-loop 重构前 agent 从空的待处理列表开始,这与基线运行时无法持久化待处理 inbox 工作的行为一致。已存储产物仍然仅追加,后续事件使用当前格式。 + +## 考虑过的替代方案 + +**将同版本记录视为不受支持。** 这符合预发布阶段的默认立场,但会使 PR 基线产生的会话无法恢复,尽管已移除的 steering 内容、轮次步骤和终止事实都有完整映射。 + +**将旧 inbox 通知回放为持久 splice。** 这些通知不是会话事件,也无法提供可信的待处理状态快照。如果无法获知每一次领取和丢弃,就推断插入操作,会让已消费的工作再次执行。 + +**将粗粒度中止记录归因于现有调用方。** 将其映射到 `user`、`parent` 或 `hook` 会虚构来源。专用的 `legacy` 原因既能保留停止分类,也不会产生虚假的审计事实。 + +**重写已存储的 JSONL 和 SQLite 记录。** 重写会违反仅追加契约,并要求为读取兼容边界建立后端专用的原子迁移机制。 + +## 后果 + +以重构基线格式写入的会话可以通过当前 AgentLoop 恢复,并完整保留 steering 内容、轮次边界、错误事实和停止分类。共享协调器契约覆盖内存、JSONL 和 SQLite 的 `load`/`inspect`/`readFrom`,包括 SQLite 后缀回退;组装后的 JSONL agent 恢复用例会验证历史 transcript(文本记录)可见,同时两个新 inbox 列表都从空状态开始。 + +此例外支持基线格式,不支持重构开发期间产生的中间格式。具体而言,它没有为更早的实验性 `agent/inbox/spliced` 载荷定义迁移。通过确切形状识别,当前格式外观相似但结构错误的记录仍会走拒绝路径,不会被猜测性地转换为有效记录。 + +## 相关资料 + +- [加载消息标识机制引入前持久化的会话](2026-07-28-load-pre-identity-session-messages.md):负责另一项同版本格式变更的确定性标识和通用只读导入边界。 +- [以抽象服务实现会话持久化](../architecture/2026-06-14-session-persistence.md):负责仅追加后端存储和恢复。 diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index f346c54ce8..98ffdb0a6f 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/docs/core-data-structures/session.i18n.yaml @@ -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 docs/core-data-structures/session.md -session.md: fa357a97d0428782cede95b878a6ca810114c894 -session.zh.md: c7ab3066549087fdefe16a6add3ea2b1734c7e30 +session.md: 79090484c567bad020715d60de81c3333c12c570 +session.zh.md: af5e8a7a4edc2acfc03faf2e4f9e7270772a3e8c diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index fa357a97d0..79090484c5 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -509,7 +509,12 @@ An explicit `boundary` lets callers fork from any stable between-turn position, ## Why a turn ended: `TurnEndReasonMap` -`turn/start` has no trigger field. The entered `user/message` batch records what entered each step, `llm/retry` records request recovery, and idle injection remains pending until a waking delivery reaches a later pre-step. `aborted.reason` retains the typed [`AgentCancelCause`](core.md#the-agent-handle) that stopped the driver. +`turn/start` has no trigger field. The entered `user/message` batch records what entered each step, `llm/retry` records request recovery, and idle injection remains pending until a waking delivery reaches a later pre-step. Live turns retain the typed [`AgentCancelCause`](core.md#the-agent-handle) that stopped the driver; persistence uses the additional `{ kind: 'legacy' }` cause only when importing a supported coarse cancellation record that did not store its caller. + +```ts type-equiv +/** Durable cancellation cause, including imports whose original coarse record carried no cause. */ +type TurnEndCancelCause = AgentCancelCause | { readonly kind: 'legacy' } +``` ```ts type-equiv /** @@ -518,7 +523,7 @@ An explicit `boundary` lets callers fork from any stable between-turn position, 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' } /** diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index c7ab306654..af5e8a7a4e 100644 --- a/docs/core-data-structures/session.zh.md +++ b/docs/core-data-structures/session.zh.md @@ -513,7 +513,12 @@ declare class Session { ## 轮次的结束原因:`TurnEndReasonMap` -`turn/start` 没有 trigger 字段。返回 enter 的 pre-step 所产生的 `user/message` 批次记录进入轮次的内容,`llm/retry` 记录请求恢复,idle 注入则保持待处理,直到后续边界领取并让它进入步骤。`aborted.reason` 保留停止驱动器的类型化 [`AgentCancelCause`](core.md#the-agent-handle)。 +`turn/start` 没有 trigger 字段。返回 enter 的 pre-step 所产生的 `user/message` 批次记录进入轮次的内容,`llm/retry` 记录请求恢复,idle 注入则保持待处理,直到唤醒交付抵达后续 pre-step。实时轮次会保留停止驱动器的类型化 [`AgentCancelCause`](core.md#the-agent-handle);只有在导入受支持的粗粒度取消记录且记录未保存调用方时,持久化才使用额外的 `{ kind: 'legacy' }` 原因。 + +```ts type-equiv +/** Durable cancellation cause, including imports whose original coarse record carried no cause. */ +type TurnEndCancelCause = AgentCancelCause | { readonly kind: 'legacy' } +``` ```ts type-equiv /** @@ -522,7 +527,7 @@ declare class Session { 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' } /** diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 104187530d..5a4f266723 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -77,7 +77,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:293`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:353`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:324`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) ## Events @@ -174,7 +174,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:216`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -190,7 +190,7 @@ Source: [`packages/core/session/src/types.ts:216`](../packages/core/session/src/ Types: [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:226`](../packages/core/session/src/types.ts) ### `command/*` @@ -419,7 +419,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -431,7 +431,7 @@ Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -484,7 +484,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -520,7 +520,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:43`](../packages 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:206`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:209`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -529,7 +529,7 @@ Source: [`packages/core/session/src/types.ts:206`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -559,7 +559,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) ### `tool/*` @@ -576,7 +576,7 @@ Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -649,7 +649,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c } ``` -Source: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) ### `turn/*` @@ -669,7 +669,7 @@ Source: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:202`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -682,7 +682,7 @@ Source: [`packages/core/session/src/types.ts:202`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) ### `user/*` @@ -699,7 +699,7 @@ Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:217`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a4153a5647..6f59ce7577 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -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;\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', diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 794c32622e..85798cfc48 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -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' } }, diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 2413ebf219..a3735fe54f 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -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 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 95b2ec9a4c..bad0cc33d6 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -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 diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index f3ef4ffeb1..e1bd5e6f44 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -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`。 ## 模型体验 diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 1e910fe001..352737e4b5 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -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' } /** diff --git a/packages/session-persistence/session-persistence/README.i18n.yaml b/packages/session-persistence/session-persistence/README.i18n.yaml index b6d4391567..eb22a3ab69 100644 --- a/packages/session-persistence/session-persistence/README.i18n.yaml +++ b/packages/session-persistence/session-persistence/README.i18n.yaml @@ -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 diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index c72d53cac7..7554d8e680 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -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` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | 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` | 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` | 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::`; 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::`; 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. diff --git a/packages/session-persistence/session-persistence/README.zh.md b/packages/session-persistence/session-persistence/README.zh.md index 15a72fd45e..0368e9d60c 100644 --- a/packages/session-persistence/session-persistence/README.zh.md +++ b/packages/session-persistence/session-persistence/README.zh.md @@ -13,9 +13,9 @@ | `locate(meta): SessionLocation \| undefined` | 在不执行 I/O 或实体化的情况下解析绝对的每会话产物目标。没有独立本地产物的后端返回 `undefined`。 | | `create(meta): Promise` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | | `append(id, events): Promise` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `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` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise` | 返回轻量元数据和不透明品牌化每日志修订,不加载事件日志。日志及其后端存储不变时,修订保持相等;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::`;工具结果的内容替换会继承其目标导入后的 id。协调器对 `load`、`inspect`、无 owner 状态的认领和 HMR 前缀接管使用同一份规范化视图,因此恢复后的会话可以追加当前事件,不会被误判为发生前缀冲突。存储仍然仅追加:读取不会重写旧记录,此后追加的每个事件都使用当前形状。这是[消息标识机制引入前的消息恢复决策](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 +后端读取会在当前形状验证前,规范化明确受支持的同版本形状。消息标识机制引入前的消息会获得确定性的 id `legacy-message::`;工具结果的内容替换会继承其目标导入后的 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 操作,最后才关闭存储句柄。 diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 0027fc8151..f1a7daaba6 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -69,7 +69,10 @@ export interface PersistenceBackend { * 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 | undefined { : undefined } +/** Whether a record contains every required key and no key outside the optional extension set. */ +function hasOnlyKeys( + record: Record, + 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 + 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() + const lastSteps = new Map() 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 { 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. diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 2db6860ee7..1e47ce99c8 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -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)) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1a6facdae9..2a6d356d24 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -343,6 +343,11 @@ "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, + { + "doc": "docs/core-data-structures/session.md", + "symbol": "TurnEndCancelCause", + "source": "packages/core/session/src/types.ts" + }, { "doc": "docs/core-data-structures/session.md", "symbol": "TurnEndReasonMap",