fix(session-persistence): invalidate stale preparations

This commit is contained in:
imccyu
2026-08-06 03:45:22 +08:00
parent 5e317371e5
commit ede74b1926
25 changed files with 353 additions and 101 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-session-preparation.md
2026-08-05-session-preparation.md: d0ec7e361c9499ec62c7a4860d7b1df9c3c1d449
2026-08-05-session-preparation.zh.md: 089a6968eb5e42d146a74f324aa7e0f25c4d311a
2026-08-05-session-preparation.md: a3dfb50c8484cfef0cadea1759125a86714d3cbc
2026-08-05-session-preparation.zh.md: 031e96f0e4446f02946f8c29ddade3fb52fe396e

View File

@@ -20,11 +20,11 @@ This refines the publication boundary from the [Agent lifecycle and ownership de
## Persisted preparation lifecycle
A coordinator-backed persistence implementation loads one cold source into a prepared Session. The backend transfers fresh, mutually unaliased metadata and events; the Session restore path validates and freezes those graphs in place instead of cloning them. The coordinator computes interrupted-turn closers and constructs the exact unpublished Session once. Its immutable header and balanced logical event log form the `SessionInspection` borrowed by readers.
A coordinator-backed persistence implementation loads one cold source into a prepared Session. The backend transfers fresh, mutually unaliased metadata and events together with the source-qualified revision that identifies those exact values; the Session restore path validates and freezes the graphs in place instead of cloning them. The coordinator computes interrupted-turn closers and constructs the exact unpublished Session once. Its immutable header and balanced logical event log form the `SessionInspection` borrowed by readers, while the revision remains internal to persistence.
`inspect(id, signal?)` does not mutate storage. Synthetic closers exist only in the prepared in-memory view, and a torn physical tail remains untouched. Same-id callers share an in-flight cold read. Once ready, the preparation may remain in a per-coordinator LRU whose capacity defaults to five and is configurable by first-party backends.
`inspect(id, signal?)` does not mutate storage. Synthetic closers exist only in the prepared in-memory view, and a torn physical tail remains untouched. Same-id callers share an in-flight cold read. Once ready, the preparation may remain in a per-coordinator LRU whose capacity defaults to five and is configurable by first-party backends. Before reusing a retained source, the coordinator reads that id's current revision; a mismatch evicts the source and repeats the cold materialization.
`prepare(id, signal?)` exclusively reserves the prepared Session. It commits any torn-tail and interrupted-turn repair, establishes the durable cursor, then returns a disposable preparation. Another same-id preparation waits until the reservation is published or released. Publication accepts only the exact reserved Session and attaches the committed cursor without rebuilding its history. Failed setup or cancellation returns an unchanged unpublished Session to the LRU; mutation or attachment consumes the reservation.
`prepare(id, signal?)` exclusively reserves the prepared Session. It confirms the retained revision before committing any torn-tail and interrupted-turn repair, establishes the durable cursor, then returns a disposable preparation. A stale source is discarded and reloaded instead of being repaired or published. Another same-id preparation waits until the reservation is published or released. Publication accepts only the exact reserved Session and attaches the committed cursor without rebuilding its history. Failed setup or cancellation returns an unchanged unpublished Session to the LRU; mutation or attachment consumes the reservation.
The legacy `load(id)` API uses the same preparation and repair machinery, then discards its reservation and returns the immutable logical view. It remains a compatibility API, not the history-to-resume reuse path. This lifecycle extends the [shared persistence coordinator](2026-06-18-shared-persistence-write-coordinator.md) while preserving the storage and recovery rules owned by the [session persistence decision](2026-06-14-session-persistence.md).
@@ -32,6 +32,8 @@ The legacy `load(id)` API uses the same preparation and repair machinery, then d
History reads use `inspect()`, so repeated pages borrow the same immutable prepared state without activating an Agent. A later resume uses `prepare()` and receives the exact Session retained by inspection; it does not read, decompress, parse, clone, validate, or freeze the complete log again.
If the durable log changes after inspection, its revision changes. The next history read or resume discards the retained Session and materializes the new log, so an old event graph cannot be associated with a newer snapshot revision.
Cold continuable-subagent access follows the same path. Descriptor authorization first inspects the child, then `ctx.agents.resume()` reserves and publishes the retained Session. This preserves the lifecycle and authorization rules in the [continuable subagent conversation decision](../feature/2026-07-28-continuable-subagent-conversations.md) while removing its duplicate cold read.
## Boundaries
@@ -41,10 +43,11 @@ Cold continuable-subagent access follows the same path. Descriptor authorization
- The cache belongs to one persistence coordinator, not a process-global Session map. Live Sessions are owned by the existing stores and never occupy preparation capacity.
- A fresh create never claims a cold persisted preparation with the same id. Persistence collisions continue to reject.
- Third-party persistence implementations retain the abstract `prepare()` fallback through `load()`. They receive the same publication interface but gain exact-object reuse only when they override preparation.
- Revision validation establishes freshness at the reuse and repair-commit points; it does not add cross-process writer exclusion to a backend.
## Verification
The shared persistence contract pins non-mutating balanced cold inspection and later repair. `persistence.spec.ts` and `preparations.spec.ts` pin same-id in-flight sharing, exact Session reuse across inspect and prepare, single repair commit, exclusive reservation, release after failed setup, ready-entry LRU eviction, append rejection during reservation, and publication of only the reserved Session. Agent-loop and continuable-subagent tests pin the common publication pipeline and inspection-to-resume path across cancellation and teardown.
The shared persistence contract pins non-mutating balanced cold inspection and later repair. `persistence.spec.ts` and `preparations.spec.ts` pin same-id in-flight sharing, exact Session reuse across inspect and prepare, revision-triggered refresh before history and resume, single repair commit, exclusive reservation, release after failed setup, ready-entry LRU eviction, append rejection during reservation, and publication of only the reserved Session. Backend tests pin that full and lightweight reads use the same revision identity. Agent-loop and continuable-subagent tests pin the common publication pipeline and inspection-to-resume path across cancellation and teardown.
## Alternatives considered

View File

@@ -20,11 +20,11 @@ agent loop智能体循环通过同一条设置与发布流水线消费这
## 持久化准备生命周期
使用协调器的持久化实现会将一个冷源加载为准备完成的 Session。后端转移新鲜、彼此无别名的元数据和事件Session 恢复路径直接验证并冻结这些对象图,不再复制。协调器计算中断轮次的 closer并且只构造一次精确的未发布 Session。其不可变 header 与平衡逻辑事件日志构成读取方借用的 `SessionInspection`
使用协调器的持久化实现会将一个冷源加载为准备完成的 Session。后端转移新鲜、彼此无别名的元数据和事件,以及标识这些精确值的来源限定 revisionSession 恢复路径直接验证并冻结这些对象图,不再复制。协调器计算中断轮次的 closer并且只构造一次精确的未发布 Session。其不可变 header 与平衡逻辑事件日志构成读取方借用的 `SessionInspection`revision 则保留在持久化内部
`inspect(id, signal?)` 不修改存储。合成 closer 只存在于准备完成的内存视图中,撕裂的物理尾部保持不变。同 id 调用方共享进行中的冷读。准备完成后,该对象可以进入每个协调器自己的 LRU第一方后端可配置容量默认保留五个。
`inspect(id, signal?)` 不修改存储。合成 closer 只存在于准备完成的内存视图中,撕裂的物理尾部保持不变。同 id 调用方共享进行中的冷读。准备完成后,该对象可以进入每个协调器自己的 LRU第一方后端可配置容量默认保留五个。协调器复用保留源之前会读取该 id 的当前 revision如果不匹配就淘汰旧源并重新完成冷实体化。
`prepare(id, signal?)` 独占预留准备完成的 Session。它先提交撕裂尾部和中断轮次修复建立持久游标,返回可 dispose 的准备对象。同 id 的另一个准备请求会等待当前预留发布或释放。发布只接受精确的预留 Session并直接附接已提交游标无需重建历史。设置失败或取消时未发生变化的未发布 Session 会返回 LRU发生变更或完成附接后系统会消费该预留。
`prepare(id, signal?)` 独占预留准备完成的 Session。它先确认保留的 revision提交撕裂尾部和中断轮次修复建立持久游标,最后返回可 dispose 的准备对象。过期源会被丢弃并重新读取,不会参与修复或发布。同 id 的另一个准备请求会等待当前预留发布或释放。发布只接受精确的预留 Session并直接附接已提交游标无需重建历史。设置失败或取消时未发生变化的未发布 Session 会返回 LRU发生变更或完成附接后系统会消费该预留。
存量 `load(id)` API 使用相同的准备和修复机制,随后丢弃其预留并返回不可变逻辑视图。它保留为兼容 API不承担历史到恢复的复用路径。该生命周期扩展了[共享持久化协调器](2026-06-18-shared-persistence-write-coordinator.md),同时继续遵循[会话持久化决策](2026-06-14-session-persistence.md)所规定的存储与恢复规则。
@@ -32,6 +32,8 @@ agent loop智能体循环通过同一条设置与发布流水线消费这
历史读取使用 `inspect()`,因此重复分页可以借用同一份不可变准备状态,而不会激活 agent。后续恢复调用 `prepare()`,直接取得检查阶段保留的精确 Session系统不会再次完整读取、解压、解析、复制、验证或冻结日志。
如果持久日志在检查后发生变化,其 revision 也会变化。下一次历史读取或恢复会丢弃保留的 Session并实体化新日志因此旧事件对象图不会被关联到较新的快照 revision。
冷 continuable subagent 访问沿用同一路径。系统先检查子会话并完成 descriptor 授权,再由 `ctx.agents.resume()` 预留并发布保留的 Session。这样既遵循 [continuable subagent 会话决策](../feature/2026-07-28-continuable-subagent-conversations.md)中的生命周期与授权规则,也消除了重复冷读。
## 边界
@@ -41,10 +43,11 @@ agent loop智能体循环通过同一条设置与发布流水线消费这
- 缓存属于单个持久化协调器,而不是进程全局 Session map。实时 Session 由现有存储持有,绝不占用准备容量。
- 新建流程绝不认领相同 id 的冷持久化准备对象。持久化冲突仍会被拒绝。
- 第三方持久化实现继续获得通过 `load()` 实现的抽象 `prepare()` 回退。它们使用相同发布接口,但只有覆盖准备流程后才能复用精确对象。
- Revision 校验在复用点和修复提交点建立新鲜性,但不会为后端增加跨进程 writer 排他。
## 验证
共享持久化契约覆盖无变更且已配平的冷检查与后续修复。`persistence.spec.ts``preparations.spec.ts` 覆盖同 id 进行中读取共享、检查与准备之间的精确 Session 复用、修复只提交一次、独占预留、设置失败后释放、就绪项 LRU 淘汰、预留期间拒绝 append以及只允许发布预留 Session。agent loop 与 continuable subagent 测试覆盖统一发布流水线,以及取消和拆卸期间从检查到恢复的路径。
共享持久化契约覆盖无变更且已配平的冷检查与后续修复。`persistence.spec.ts``preparations.spec.ts` 覆盖同 id 进行中读取共享、检查与准备之间的精确 Session 复用、在历史读取与恢复前由 revision 触发刷新、修复只提交一次、独占预留、设置失败后释放、就绪项 LRU 淘汰、预留期间拒绝 append以及只允许发布预留 Session。后端测试覆盖完整读取与轻量读取使用同一 revision 身份。agent loop 与 continuable subagent 测试覆盖统一发布流水线,以及取消和拆卸期间从检查到恢复的路径。
## 考虑过的替代方案