# Session Persistence Contract（唯一事实源）

最后更新：2026-05-24
状态：规范性（Normative）

本文档定义 Formax 本地 session 文件、resume 语义、以及 app-server 恢复 stale input 的共享合同。

范围：
- 本地 session 存储根目录、文件命名与 active lookup 边界
- REPL / SDK query 的 file-backed persistence 与 resume 语义
- SDK `unstable_v2_resumeSession` 的 in-process resume 语义
- app-server `thread/start` / `thread/resume` 的 provisional thread 与 stale input 恢复边界
- `listSessions` / `getSessionMessages` 的目录作用域

不在范围内：
- transcript reset / remount 的 UI 行为
- hooks、permissions、tool transcript 的独立合同
- archived thread 的完整管理流程

相关文档（信息性镜像）：
- `docs/contracts/transcript-surface-contract.md`
- `docs/contracts/interactive-input-contract.md`
- `docs/contracts/app-server-interaction-contract.md`
- `docs/references/app-server-api-reference.md`

相关实现（规范锚点）：
- `packages/core/src/features/repl/sessionSave/paths.ts`
- `packages/core/src/features/repl/sessionSave/writer.ts`
- `packages/core/src/features/repl/sessionSave/reader.ts`
- `packages/core/src/features/repl/sessionSave/sessionMemorySidecar.ts`
- `packages/core/src/features/repl/controller/session/sessionRollingMemory.ts`
- `packages/core/src/sdk/query/resume.ts`
- `packages/core/src/sdk/query/persistence.ts`
- `packages/core/src/sdk/query/runner.ts`
- `packages/core/src/sdk/session/core.ts`
- `packages/core/src/sdk/sessions.ts`
- `packages/core/src/app-server/threadStore.ts`
- `packages/core/src/app-server/store/sessionEventReader.ts`
- `packages/core/src/app-server/server.ts`

规范关键字约定：
- `MUST`、`SHOULD`、`MAY` 采用 RFC 2119 语义。

## 1. 权威模型

`SES-001`  
跨进程可恢复的 session 状态 MUST 以本地 JSONL session 文件为准；其根目录与命名规则 MUST 由 `packages/core/src/features/repl/sessionSave/paths.ts` 定义。

`SES-002`  
active session root MUST 位于 `<config-root>/sessions`；archived session root MUST 位于 `<config-root>/archived_sessions`。

`SES-003`  
`<config-root>` 的解析 MUST 复用当前 session path 逻辑：
1. 若显式配置 `FORMAX_CONFIG_DIR`，使用其 global config root。
2. 否则若存在 `FORMAX_VITEST_SESSION_CONFIG_DIR`，使用该测试隔离目录。
3. 否则使用默认 global config root。

`SES-004`  
active session 文件路径 MUST 保持当前按日期分层的格式：
`sessions/YYYY/MM/DD/session-<iso-timestamp>-<sessionId>.jsonl`

`SES-005`  
session 文件的可读 replay、summary 与 history 解释 MUST 以 `packages/core/src/features/repl/sessionSave/reader.ts` 为准；写入格式与 session id 合法性 MUST 以 `packages/core/src/features/repl/sessionSave/writer.ts` 为准。

## 2. 存储生命周期

`SES-101`  
REPL / SDK query 的 persisted session MAY 延迟物化；在真正需要写盘前，不要求提前创建 session 文件。

`SES-102`  
app-server `thread/start` 当前 MUST 返回 provisional thread。  
该阶段 thread id 已分配，但本地 session 文件 MAY 尚不存在。

`SES-103`  
当 app-server 后续需要 durable thread file 时，MUST 通过当前 `ensureThreadFile(...)` 路径收敛：
1. 先查找现有 session 文件
2. 若仍不存在且 thread 仍是 provisional，则创建新 session 文件
3. 若 provisional thread 已有 label，创建文件时 MUST 一并写入 `session_rename { label, source: "manual" }`

`SES-103A`
`session_rename` is the durable authority for formal thread titles. Its stable payload is:
1. `label: string`
2. `source?: "manual" | "auto_title"`

Missing or unknown `source` MUST be projected as `legacy`. Legacy labels are formal labels and MUST be protected from automatic overwrite.

`SES-103B`
`lastUserPrompt` in session summaries is preview/snippet compatibility data only. Readers MAY preserve legacy `firstUserPrompt`-preferred projection for existing non-web consumers and old session files, but title generation/title display MUST NOT treat it as a formal title fallback.

`SES-103C`
Automatic title failures MAY be recorded as best-effort `auto_title_attempt` events. Stable statuses are `empty`, `failed`, and `persist_error`. These events count toward retry diagnostics/budget; aborted/interrupted generation MUST NOT consume retry budget. A successful generated title is represented by `session_rename { label, source: "auto_title" }`, not by a success attempt event.

`SES-104`  
对仍然是 provisional 且尚未物化文件的 thread 执行 `thread/resume` 时，MUST 返回该 thread 本身，且 `staleInputs` MUST 为空数组。

`SES-105`
对 app-server `thread/resume` 而言，当前 SHOULD 同时暴露与该 session 最近一次 compact boundary 对齐的最小 compact protocol 摘要（`latestCompactBoundary`）。
该字段：
1. MUST 来自同一 session 文件 replay / compact boundary 语义，而不是客户端本地推导
2. MUST NOT 创建新的 persisted authority model
3. 当前若不存在 compact boundary，MUST 显式返回 `null`

`SES-105B`
对 app-server `thread/resume` 而言，当前 SHOULD 同时暴露与该 session 最近一次 request-time collapse event 对齐的最小摘要（`latestRequestCollapse`）。
该字段：
1. MUST 来自同一 session 文件中的 persisted request-collapse event，而不是客户端从 restored history 或 timeline rows 反推
2. MUST NOT 创建新的 persisted history rewrite 语义
3. 当前若不存在 request-collapse event，MUST 显式返回 `null`

`SES-106`
对 app-server `thread/resume` 而言，当前 SHOULD 同时暴露 `pendingSessionMemoryRestore` 结构化摘要。
该字段：
1. MUST 基于与 next-turn-only reminder block 相同的 canonical restore-artifacts 路径派生
2. MUST NOT 重新引入第二套 reminder 组装路径
3. MUST NOT 升级 sidecar 成为新的 persisted authority
4. SHOULD 只暴露恢复当前任务语义真正有用的最小 structured task state（如 `mode`、`planPath`、`recentFiles`、`recentUserPrompts`、`recentSkills`、`recentSubagentTypes`、`recentDeferredToolNames`、`recentTaskHints`、`planExcerpt`、`todoSummary`）
5. `recentDeferredToolNames` / `recentTaskHints` MUST be bounded best-effort hints derived from prior successful tool calls; they MUST NOT rehydrate deferred tool runtime state, resume background tasks, or become a new persisted authority.

`SES-107`
Context-compression message identity metadata MAY be carried under `PromptMessage.meta.messageIdentity` and compact preserved-segment metadata. The minimum identity shape is:
1. `schemaVersion: 1`
2. stable `id`
3. optional `parentId`
4. `fingerprint`
5. `source` (`explicit` or `legacy_fallback`)

Explicit identities SHOULD come from persisted message metadata when available. Legacy fallback identities MAY be derived from projection-local order plus message fingerprint; they are deterministic for the same replay/projection but MUST NOT be treated as strong cross-session identity and MUST NOT drive destructive replay by themselves. Duplicate identities, missing parent ids, and old compact boundaries without identity fields MUST remain non-destructive: raw transcript, UI scrollback, and model-facing baseline membership MUST continue to fall back to fingerprint/count guards until an explicit relink contract upgrades the behavior.

## 3. SDK Resume 语义

`SES-201`  
`unstable_v2_resumeSession(sessionId, options)` MUST 是 in-process resume。  
它 MUST 只从当前 Node.js 进程内的 `sessionStore` 恢复状态，MUST NOT 扫描本地 session 文件做跨进程恢复。

`SES-202`  
当 `sessionStore` 中不存在目标 `sessionId` 时，`unstable_v2_resumeSession` MUST 抛错，并明确当前仅支持 in-process resume。

`SES-203`  
当目标 session 仍有 active stream 或 pending prompt 时，`unstable_v2_resumeSession` MUST 拒绝恢复，以避免状态分叉。

`SES-204`  
in-process resume 成功后，session options MAY 与传入 options 合并；该行为只影响当前进程内会话句柄，不改变本地 session 文件查找逻辑。

`SES-205`  
`query(..., { resume })` 与 `query(..., { continue })` MUST 属于 file-backed resume 路径；其历史恢复来源 MUST 是本地 session 文件，而不是内存 `sessionStore`。

`SES-206`  
`options.resumeSessionAt` 当前 MUST 仅作为 compatibility no-op 接受；不得在当前实现中改变 replay 截断点或 message 选择结果。

`SES-207`  
`options.continue=true` 与 `options.resume=<sessionId>` MUST 互斥；同时提供时 MUST 失败。

`SES-208`  
`options.resume=<sessionId>` 时，系统 MUST 按当前 cwd 作用域查找对应 session 文件；若找不到，MUST 明确报错该 session 不在本地 session storage 中。

`SES-209`  
`options.continue=true` 时，系统 MUST 在当前 cwd 作用域解析 latest session 文件；若不存在 latest session，MUST 回退为“无历史恢复”的普通启动。

`SES-210`  
当 `options.sessionId` 与 `options.resume` 或 `options.continue` 的目标 session 冲突时，若 `forkSession !== true`，MUST 报冲突错误。

`SES-211`  
当 `forkSession === true` 时，resume / continue 路径 MUST 只复用历史内容，不复用原 session 文件绑定；新的 query 持久化 MUST 视为新会话写入。

## 4. Query Persistence 与 Session Discovery

`SES-301`  
query 持久化当前 MUST 只在以下任一条件满足时启用：
1. `options.persistSession === true`
2. `options.enableFileCheckpointing === true`

`SES-302`  
当 file-backed resume 绑定了既有 `sessionFilePath` 且未 `forkSession` 时，持久化 writer MUST 重新打开原文件并追加写入；不得默默新建第二个 active session 文件。

`SES-303`  
当未绑定既有 `sessionFilePath` 时，持久化 writer MUST 创建新的 session 文件。

`SES-304`  
query 持久化后的 session 文件 MUST 至少支撑以下能力继续工作：
1. `listSessions`
2. `getSessionMessages`
3. 后续 `options.resume`
4. 后续 `options.continue`

`SES-304A`
当会话经历 compact 后，history snapshot MAY 在 compaction summary 前写入 metadata-only compact boundary message。
该 boundary：
1. MUST 能被 replay / resume 原样恢复
2. MUST NOT 依赖可见文本内容来识别
3. MUST 在真实 prompt 组装前参与“最新 compact boundary 后 continuation view”的切片计算；boundary 自身不得作为模型可见历史正文发送
4. 当前 SHOULD 至少包含：`trigger`、`preTokens`、`summaryKind`、`keepStrategy`
   - `summaryKind` 当前 MAY 为 `model_summary` 或 `session_memory`
   - `keepStrategy` 当前 MAY 为 `keep_last_turns` 或 `keep_combo`
5. 当前 SHOULD 允许携带最小 `rehydrationPlan`，用于声明 compact 后优先恢复的状态集合；当前最小集合 MAY 包含 `recent_files`、`plan_state`、`todo_state`、`mode_state`
6. 当前当 `recent_files` 已实际注入 compaction summary reminder 时，对应 `rehydrationPlan.items[*].status` SHOULD 升为 `applied`
7. 当前当 compact summary 已实际注入 plan excerpt、todo summary、mode text 时，对应 `plan_state`、`todo_state`、`mode_state` 的 `status` SHOULD 升为 `applied`
8. 当前若 compact 已注入 rehydration 文本，boundary SHOULD 额外记录 `rehydrationCost`，至少包含 `sectionCount` 与 `estimatedTokens`
9. 当前 boundary SHOULD 允许携带最小 `preservedSegment` metadata，至少包含 `continuationMessageCount`、`preservedTailMessageCount`、`summaryFingerprint`、`headFingerprint`、`tailFingerprint`
10. 新写入的 `preservedSegment` SHOULD 额外携带 `messageFingerprints` 与 `messageIdentities`，按 compact summary + preserved tail 的 continuation 顺序记录每条消息 fingerprint / identity，用于检测 head/tail 之间的中间消息漂移；旧 boundary MAY 缺省 identity 字段并退回 fingerprint/count 校验。
11. 新写入的 `preservedSegment` SHOULD 额外携带命名 identity：`summaryIdentity`、`headIdentity`、`anchorIdentity`、`tailIdentity`。`summaryIdentity` 指 compact summary continuation message；`headIdentity` 指 preserved tail 的第一条消息；`tailIdentity` 指 preserved tail 的最后一条消息；`anchorIdentity` 指 preserved tail 的中间锚点（单条 tail 时与 head/tail 相同）。若没有 preserved tail，`headIdentity`、`anchorIdentity`、`tailIdentity` MAY 为 `null`。
12. 恢复 active continuation 时，preserved-segment relink MUST 先于 durable snip removal / collapse replay。Relink 只能在 compact summary fingerprint 匹配、preserved tail 在当前 continuation 中整体缺失、且可在 boundary 前历史中按 explicit identity 或旧 fingerprint guard 唯一匹配时补回。若 anchor 缺失、head/tail fingerprint 漂移、匹配重复、或当前 continuation 已有部分 preserved tail，MUST 跳过 relink，避免重复 tail 或误重排。
13. 当前 `preservedSegment` metadata SHOULD 足以支撑最小 continuation-view 校验与后续 relink / resume 扩展；若没有 preserved tail，`headFingerprint` 与 `tailFingerprint` MAY 为 `null`

`SES-304B`
会话持久化当前 MAY 额外维护一个与 session JSONL 相邻的 rolling session memory sidecar。
该 sidecar：
1. 当前 MUST 使用与 session 文件同目录、同 basename 的 `.memory.json` 后缀路径
2. 当前内容 SHOULD 保存最新 `SessionMemoryDraft`
3. 当前 MAY 在 turn 完成后异步刷新；不得阻塞主 turn 完成路径
4. 当前 MUST NOT 取代 JSONL session 文件的 replay / resume 权威性
5. 当前 auto compact MAY 先读取该 sidecar，以 session memory 生成 compaction summary；若 sidecar 不存在、不可读或不可用，MUST 静默回退到 model summary compact
6. 当前 REPL `/resume`、CLI `resumeLast`、SDK `query(..., { resume })`、SDK `query(..., { continue: true })` 与 app-server `thread/resume` 在基于 session 文件恢复 active history 后，MUST best-effort 刷新该 sidecar
7. `SES-304B.6` 的刷新输入 MUST 使用 boundary-aware restore 之后的 active history，而不是完整 replay.history 原样
8. `SES-304B.6` 的 sidecar 刷新 MUST 是 best-effort；刷新失败时不得中断 resume / continue / restore 主流程
9. sidecar 刷新当前 MAY 使用恢复入口可得的最小上下文：
   - REPL `/resume` SHOULD 传入当前 REPL mode 与当前 `planPath`
   - CLI `resumeLast`、SDK file-backed resume/continue 与 app-server `thread/resume` 当前 SHOULD 优先沿用已有 session memory sidecar 里的 `mode` / `planPath`
   - 若不存在可用 sidecar，上述入口 MAY 退化为 `mode = normal` 且 `planPath = null`
10. 当前 REPL `/resume`、CLI `resumeLast`、SDK file-backed `resume/continue` 与 app-server `thread/resume` 在 restore 成功后，MAY 基于 sidecar 额外派生一条 **仅下一轮请求可见** 的 session-memory reminder block
11. `SES-304B.10` 的 reminder block MUST 通过 request-time injection 路径消费；不得写回 persisted history，也不得替代 boundary-aware restore 后的 active history
12. 对 app-server `thread/resume` 而言，`SES-304B.10` 的 reminder block SHOULD 由服务端缓存，并在下一次成功的 `turn/start` / turn-dispatch 上消费一次；客户端不需要重新组装第二套 reminder

`SES-304C`
当 file-backed restore 需要把 persisted history 恢复成“下一轮 active prompt baseline”时，系统 MUST 以最近 compact boundary 之后的 continuation view 为准，而不是直接把完整 replay.history 原样作为 active history。  
该规则当前至少适用于：
1. REPL `/resume`
2. CLI `resumeLast`
3. SDK `query(..., { resume })`
4. SDK `query(..., { continue: true })`

`SES-304D`
`SES-304C` 的 boundary-aware restore MUST 满足以下约束：
1. session JSONL replay 仍 MUST 保持原样权威；不得为了 restore 方便而改写历史 replay 语义
2. compact boundary 自身 MUST NOT 进入恢复后的 active history
3. compact summary 与 preserved tail 若位于最新 boundary 之后，MUST 继续作为恢复后的 active history 一部分保留
4. 若会话不存在 compact boundary，恢复结果 MUST 退化为“去掉 boundary message 后的完整 persisted history”

`SES-304E`
当一次真实模型请求使用了 request-time context collapse projection 时，session 持久化当前 SHOULD 追加最小运行时事件 `request_collapse_applied`。  
该事件：
1. 当前 MAY 在同一 turn 内出现多次；至少允许区分 `phase = "initial"` 与 `phase = "reactive_retry"`
2. 当前 SHOULD 至少包含：`collapsedHeadMessageCount`、`estimatedTokensSaved`
3. 当前 SHOULD 尽量携带最小 request-recap metadata，用于后续 diagnostics / replay tooling / state-store 设计；最小集合 MAY 包含 `keepLastTurns`、`preservedTailMessageCount`、`retainedCompactSummary`、`recapFingerprint`
4. 当前 MUST 只描述 request-time projection；不得被解释为 persisted history 已被 rewrite

`SES-304F`
当一次真实模型请求因为上下文超限类错误触发 reactive compact fallback 且 fallback 成功进入重试时，session 持久化当前 SHOULD 追加最小运行时事件 `reactive_compact_applied`。  
该事件：
1. 当前 SHOULD 至少包含：`triggerKind`、`strategy`
2. 当前 MAY 包含 `triggerDetail`，用于 diagnostics / inspection surface 解释最近一次 overflow 来源
3. 当前 `strategy` 允许的稳定值至少包括：`session_memory`、`model_summary`
4. 当前 MUST 只描述 reactive fallback 事实；不得被解释为 persisted history 已被 rewrite

`SES-305`  
`listSessions(options)` 与 `getSessionMessages(sessionId, options)` MUST 共享同一目录作用域规则：
1. `options.dir` 存在时以其为 lookup cwd
2. 否则以当前 `process.cwd()` 为 lookup cwd

`SES-306`  
`listSessions` MUST 对单个损坏或不可读 session 文件保持弹性；单文件失败 MUST NOT 让其余 session 从列表中消失。

## 5. App-Server Recovery 与 Stale Inputs

`SES-401`  
app-server `thread/resume` 的持久化恢复 MUST 基于 session 文件，而不是仅凭内存线程状态。

`SES-402`  
`staleInputs` MUST 由 session 文件中的未解决输入推导：
1. 扫描 `app_input_requested`
2. 用后续 `app_input_resolved` 抵消已解决项
3. 剩余项全部转成 stale input

`SES-403`  
`thread/resume` 返回的 stale input MUST 统一映射为：
1. `status = "expired"`
2. `reason = "server_restart"`
3. `resolvedAt = 恢复扫描时刻`

`SES-404`  
app-server 在 `thread/resume` 返回 stale input 后，MUST 记住这些 `inputId` / `toolUseId`，并在后续提交时将其视为已过期输入处理。

`SES-405`  
客户端在收到 `thread/resume.staleInputs` 后，MUST 以服务端恢复结果为准更新本地 pending-input 状态；不得继续把这些输入视为可提交。

当 app-server `thread/resume` 同时恢复出 session-memory reminder block 时：
1. reminder block MUST 仅对下一次成功的 turn 启动或 materializing `/compact` dispatch 生效；`/compact` MUST consume/clear the pending restore state without injecting the reminder into the compact summary prompt
2. reminder block MUST NOT 作为 `thread/resume` 的 persisted history 结果写回 session JSONL
3. 服务端 MAY 在 `/context` 的 next-turn fixed groups 中把它暴露为 pending restore injected blocks，以便 diagnostics 解释当前 restore consumption 语义
4. 服务端当前 SHOULD 同步把 `latestCompactBoundary` 暴露给 restore surface，这样客户端在 resume 后无需额外调用 `thread/read` 才能得到最近 compact 的 canonical protocol facts
5. 服务端当前 SHOULD 同步把 `latestRequestCollapse` 暴露给 restore surface，这样客户端在 resume 后无需额外调用 `thread/read` / `thread/messages` 才能得到最近 request-time collapse fact
6. 服务端当前 SHOULD 同步把 `pendingSessionMemoryRestore` 暴露给 restore surface；若该 reminder 仍处于待消费窗口，`thread/replay` 也 SHOULD 暴露同一份摘要，直到下一次成功的 `turn/start` / turn-dispatch / materializing `/compact` 消费掉它

## 6. 变更流程

当修改 session 文件根目录、resume 选择逻辑、provisional thread 物化、stale input 恢复或 SDK session discovery 行为时：
1. 先更新本文件。
2. 再更新 `docs/contracts/app-server-interaction-contract.md` 与 `docs/references/app-server-api-reference.md` 中受影响的摘要。
3. 再更新 `packages/core/src/sdk/README.md` 等 code-local deep dive。
4. 若影响 transcript reset 或 input lifecycle，再同步相关合同。

若实现与本合同冲突，应视为实现漂移并立即收敛修正。
