From 406c82d1a7129848780f1ae21ab6cab4c10caa0c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 23 Jul 2026 17:57:26 +0800 Subject: [PATCH 1/7] refactor: eagerly persist session events --- ...18-shared-persistence-write-coordinator.md | 10 +- ...collapse-persistence-flush-state.i18n.yaml | 6 + ...-07-23-collapse-persistence-flush-state.md | 39 ++++ ...-23-collapse-persistence-flush-state.zh.md | 39 ++++ docs/cordis-catalog/services.md | 7 +- docs/core-data-structures/persistence.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-sqlite/README.md | 2 +- .../session-persistence/README.md | 8 +- .../session-persistence/src/coordinator.ts | 177 ++++++++---------- .../session-persistence/src/index.ts | 7 +- .../tests/persistence.spec.ts | 112 +++++++++-- 13 files changed, 273 insertions(+), 140 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md create mode 100644 .agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 27360087a2..effcfc025b 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -`dsh-session-persistence-jsonl` and `dsh-session-persistence-sqlite` intentionally prove the same `SessionPersistence` contract over different storage media, but their write-path orchestration was duplicated: per-session state, `session/created` adoption, backend-specific prefix reads, write-behind buffers, serialized flush chains, HMR seeding, and dispose drains. The pure seed-prefix collision and serializability guards had already moved into the seam package; the remaining orchestration was still correctness-heavy and received the same fixes twice. A code-level diff showed the two backends were byte-identical — or same-algorithm — for ALL of it: the four maps (`states`/`buffers`/`chains`/`inits`), `installWritePath`, `initFor`, `onCreated`'s four cases, `flush`, `drain`, `serialize`, `adopt`, `adoptLivePrefix`, `assertVersion`, and the `create`/`append`/`load` skeletons. Only the storage primitives (write bytes vs. INSERT rows) differed. +`dsh-session-persistence-jsonl` and `dsh-session-persistence-sqlite` intentionally prove the same `SessionPersistence` contract over different storage media, but their write-path orchestration was duplicated: per-session state, `session/created` adoption, backend-specific prefix reads, write-behind control, per-id operation serialization, HMR seeding, and dispose drains. The pure seed-prefix collision and serializability guards had already moved into the seam package; the remaining orchestration was still correctness-heavy and received the same fixes twice. Only the storage primitives (write bytes vs. INSERT rows) differed. ## Decision @@ -12,7 +12,9 @@ Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistenc Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The Agent Note's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks; it cannot reach the coordinator's private orchestration state, and the public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator at all. -The coordinator retires each live session from its `session/disposed` notification: it waits for that exact Session object's initialization, serializes a final drain, and then removes the owned state, buffer, and init entries. Failed drains retain their buffers for backend teardown to retry. Settled per-id chain tails remove themselves only when they are still the current tail, so a completion cannot erase a newer operation for the same id. Backend teardown unregisters the write-path listeners before awaiting all admitted retirements, remaining buffers, and chains, then closes the backend. +The coordinator holds one controller for each exact live `Session`; the controller combines initialization, pending events, and the shared flush promise. Each `session/event` starts an eager drain, and `session/flush` observes quiescence rather than initiating the ordinary write path. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns this lifecycle. + +The coordinator retires a session from `session/disposed`: it waits for the controller's initialization and current flush, serializes a final drain, and removes the controller and owned per-id state only after success. A failure leaves the controller discoverable for backend teardown to retry. Settled per-id chain tails remove themselves only when they are still current, so a completion cannot erase a newer operation for the same id. Backend teardown unregisters write-path listeners, flushes every remaining controller, awaits per-id operations, and then closes the backend. ### The hook interface (`PersistenceBackend`) @@ -32,7 +34,7 @@ The single design choice that keeps the seam clean: the crash-repair "where is t ## Testing -The shared `runPersistenceContract` (public-API contract) keeps running for every backend. `runCoordinatorContract` (`tests/coordinator-contract.ts`) holds the write-path orchestration — adoption, HMR, collision, session and backend disposal drains, and crash-tail repair — and runs once per backend through a `CoordinatorFixture` (an in-memory reference + jsonl + sqlite). Coordinator-specific tests pin retirement map cleanup, same-id chain-tail races, failed-drain retry, and close ordering. The per-backend specs retain storage mechanics only (JSONL: path safety, fsync rollback, bucket listing; SQLite: schema version, `scanRows`, transaction rollback). A through-coordinator torn-tail→load→`commitRepair` test per real backend (via a `corruptTail` fixture hook) keeps the coordinator's torn-marker repair branch covered under the 100% per-file gate — the contract crash test only produces synthetic closers, never a torn marker, so it could not reach that branch. +The shared `runPersistenceContract` (public-API contract) runs for every backend. `runCoordinatorContract` (`tests/coordinator-contract.ts`) covers adoption, HMR, collision, disposal drains, and crash-tail repair through an in-memory reference, JSONL, and SQLite. Coordinator-specific tests cover eager follow-up batches, live-controller cleanup, same-id chain-tail races, failed-drain retry, and close ordering. The per-backend specs retain storage mechanics only. A through-coordinator torn-tail repair test per real backend keeps the opaque-marker branch covered. ## Alternatives considered @@ -41,4 +43,4 @@ The shared `runPersistenceContract` (public-API contract) keeps running for ever ## Consequences -The coordinator adds one indirection, an opaque torn marker, and detached session-retirement tasks, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves uncommitted buffers, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: collision checks reuse `loadStored`, materialization stays atomic inside `appendBatch`, and listing bypasses the coordinator. New backends implement storage primitives rather than copy the event-buffer-flush lifecycle. +The coordinator adds one indirection and an opaque torn marker, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the coordinator contains retirement failures, preserves pending events in the live controller, and makes backend teardown the final quiescence boundary. Its hook surface stays narrow: collision checks reuse `loadStored`, materialization stays atomic inside `appendBatch`, and listing bypasses the coordinator. New backends implement storage primitives rather than copy the write lifecycle. diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml new file mode 100644 index 0000000000..117e2b7202 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.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 +2026-07-23-collapse-persistence-flush-state.md: 69403fe3c2ee556cb10593fd43857e0d844242df +2026-07-23-collapse-persistence-flush-state.zh.md: 0b38ee26b9e6273672cbc218826c0dc399158a71 diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md new file mode 100644 index 0000000000..69403fe3c2 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md @@ -0,0 +1,39 @@ +# Agent Note: Collapse live persistence into one flush controller + +Status: implemented + +English | [中文](2026-07-23-collapse-persistence-flush-state.zh.md) + +## Problem + +The persistence coordinator represented one live session's write lifecycle with separate buffer, initialization, and retirement containers plus the per-id operation chain. Those structures mirrored the same fact: whether that exact `Session` still had initialization or events that must settle before its state could be released. The checkpoint-only drain also kept every event volatile until another plugin requested `session/flush`, even though the backend could begin durability work without blocking the synchronous producer. + +## Decision + +Each live `Session` has one controller containing `pending`, `init`, and the optional current `flush` promise. A `session/event` listener copies the frozen event into `pending` and immediately schedules `ensureFlush()`. Calls during an active write reuse the same promise. The drain snapshots one stable pending prefix and removes it only after `appendBatch` commits; events admitted during the write remain after that prefix and schedule one follow-up batch. + +`session/flush` is an observation barrier. It waits for initialization and repeatedly awaits or starts the controller's flush until neither a current promise nor pending events remain. An eager failure is logged without rejecting the synchronous event producer, retains the complete batch, and is retried by the next explicit flush, retirement attempt, or backend teardown. Explicit flush and teardown still surface the failure if that retry rejects. + +Initialization now enters the existing per-id operation chain once and calls the unserialized core operations while it owns that turn. The chain remains separate from the live controller because detached public `create`/`append`/`load` calls can race without a `Session` object and still require identity-level serialization. + +The live-controller map is also the retirement registry. Successful retirement drains and removes its controller; failed retirement leaves it in the map. Backend teardown stops event admission, flushes every controller still present, awaits remaining per-id operations, and closes the backend. No separate retirement set is needed to rediscover unfinished work. + +## Alternatives considered + +**Keep checkpoint-only write-behind.** This can form larger batches, but makes durability depend on a separately mounted checkpoint policy and maximizes the crash-loss window between checkpoints. Eager scheduling still coalesces synchronous bursts and events arriving during an active write. + +**Use one coordinator-wide flush promise.** The attachment pattern works for one file, but a global promise would serialize unrelated sessions. One controller per live session preserves independent backend progress while the per-id chain protects same-identity operations. + +**Latch the first eager error permanently.** This makes every later flush deterministic, but prevents the existing teardown retry from recovering a transient storage failure. Retaining the batch without latching the error preserves both observability and retry. + +## Verification + +- A focused coordinator test gates the first append, admits another event during that write, and observes an automatic second durable batch without calling `session/flush`. +- The shared coordinator contract still covers live adoption, collisions, crash repair, and session/backend disposal over the in-memory, JSONL, and SQLite backends. +- Failure and teardown tests keep rejected batches pending, retry them before close, and prove an in-flight controller delays backend close. + +## Consequences + +The coordinator has three long-lived containers: persisted identity state, live-session controllers, and per-id operation chains. Eager writes reduce the ordinary crash-loss window and remove separate buffer, initialization, and retirement registries. They can produce more backend batches than checkpoint-only draining; same-tick bursts and events admitted during one write still coalesce. + +`session/flush` no longer chooses when ordinary persistence begins. It remains the ordering and error-observation boundary used by the loop and checkpoint policy, so a successful checkpoint still means every event admitted before its completion is durable. diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md new file mode 100644 index 0000000000..0b38ee26b9 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 将实时持久化归并到单个刷新控制器 + +Status: implemented + +[English](2026-07-23-collapse-persistence-flush-state.md) | 中文 + +## 问题 + +持久化协调器使用彼此独立的缓冲区、初始化容器和退役容器,以及按 id 划分的操作链,表示一个活跃会话的写入生命周期。这些结构反映的是同一个事实:该 `Session` 是否仍有初始化操作或事件必须完成,之后才能释放其状态。仅由检查点触发的排空还会让每个事件都停留在易失状态,直至另一个插件请求 `session/flush`,尽管后端可以在不阻塞同步生产方的情况下开始持久化工作。 + +## 决策 + +每个活跃的 `Session` 都有一个控制器,其中包含 `pending`、`init` 和可选的当前 `flush` promise。`session/event` 监听器将冻结的事件复制到 `pending`,并立即调度 `ensureFlush()`。活跃写入期间的调用复用同一个 promise。排空操作会对待处理事件中一个稳定的前缀生成快照,并且只在 `appendBatch` 提交后移除该前缀;写入期间接纳的事件保留在该前缀之后,并调度一个后续批次。 + +`session/flush` 是观测屏障。它等待初始化完成,并反复等待或启动控制器的刷新,直至当前 promise 和待处理事件均不存在。即时写入失败会被记录,但不会拒绝同步事件生产方;完整批次会保留下来,由下一次显式刷新、退役尝试或后端资源销毁重试。若该次重试仍失败,显式刷新和资源销毁仍会向调用方暴露失败。 + +初始化只进入现有的按 id 操作链一次,并在占有该轮执行权时调用未串行化的核心操作。该操作链与活跃控制器保持分离,因为公共 `create`、`append`、`load` 调用即使没有 `Session` 对象仍可能发生竞态,依然需要按标识串行执行。 + +活跃控制器映射同时也是退役注册表。退役成功时,系统排空并移除其控制器;退役失败时,控制器保留在映射中。后端资源销毁会停止接纳事件,刷新所有仍存在的控制器,等待其余按 id 操作完成,然后关闭后端。无需另设退役集合来重新发现未完成的工作。 + +## 备选方案 + +**保留仅由检查点触发的延后写入。** 这种方式可以形成更大的批次,但会让持久性依赖另行挂载的检查点策略,并使检查点之间因崩溃而丢失数据的窗口达到最大。即时调度仍会合并同步突发事件,以及活跃写入期间到达的事件。 + +**在整个协调器范围内使用一个刷新 promise。** 这种挂接方式适用于单个文件,但全局 promise 会串行化互不相关的会话。每个活跃会话各有一个控制器,既能让不同会话的后端操作独立推进,又由按 id 操作链保护同一标识的操作。 + +**永久锁存首次即时写入错误。** 这会让后续每次刷新都得到确定的结果,却会阻止现有的资源销毁重试从暂时性存储故障中恢复。保留批次但不锁存错误,可以同时保留可观测性和重试能力。 + +## 验证 + +- 一个针对协调器的测试会阻塞第一次追加,在该次写入期间接纳另一个事件,并在不调用 `session/flush` 的情况下观测到自动执行的第二个持久批次。 +- 共享协调器契约仍覆盖内存、JSONL 和 SQLite 后端上的活跃会话接管、冲突、崩溃修复,以及会话和后端的资源释放。 +- 失败和资源销毁测试会让写入失败的批次保持待处理,在关闭前重试这些批次,并证明尚在执行的控制器会延迟后端关闭。 + +## 后果 + +协调器有三个长生命周期容器:持久化的标识状态、活跃会话控制器和按 id 操作链。即时写入缩短了通常情况下因崩溃而丢失数据的窗口,并移除了彼此独立的缓冲区、初始化注册表和退役注册表。与仅由检查点触发的排空相比,这种方式可能产生更多后端批次;同一轮事件循环内的突发事件和一次写入期间接纳的事件仍会合并。 + +`session/flush` 不再决定普通持久化何时开始。它仍是循环和检查点策略使用的顺序与错误观测边界,因此检查点成功仍表示在其完成前接纳的每个事件都已持久化。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0fcb2fc28e..3fd4fba0fc 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -904,10 +904,9 @@ abstract locate(meta: SessionHeader): SessionLocation | undefined abstract create(meta: SessionHeader): Promise /** - * Durably persist a batch of events (called from the write-behind drain at - * the `session/flush` checkpoint). Honors the append-only and contiguous-seq - * contracts: the first event's `seq` MUST equal the stored next-seq (after - * `load` has durably closed any interrupted turn). Rejects non-JSON- + * Durably persist a batch of events. Honors the append-only and contiguous- + * seq contracts: the first event's `seq` MUST equal the stored next-seq + * (after `load` has durably closed any interrupted turn). Rejects non-JSON- * serializable `event.data` with an error naming the offending event type. * @param id - the session the batch belongs to. * @param events - the contiguous batch to persist, in seq order. diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index aa789a6ee7..9327c2e4dd 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -6,7 +6,7 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite ## The flush checkpoint -`session/event` is a *synchronous* notification; persistence plugins buffer it (write-behind) until `session/flush`. The loop awaits an ordinary turn's checkpoint before claiming the next queue item; synchronous idle `inject()` schedules its checkpoint without blocking `send()`, and disposal still drains it. A successful flush durably commits the closed turn as one unit; a rejecting flush is reported through `agent/error` and the logger — never as a session event past the closed turn — while the backend keeps its buffered events for the next flush. +`session/event` is a *synchronous* notification; persistence plugins copy the event into a per-session controller and start an eager write without blocking the producer. Concurrent events share the current batch, and events admitted during that write trigger a follow-up batch. `session/flush` waits until no current or pending batch remains, so the loop still uses it as the ordering and error-observation checkpoint before claiming the next ordinary turn. A rejected eager write retains its events; an explicit flush retries them and reports failure through `agent/error` and the logger, never as a session event past the closed turn. Disposal performs the same final drain. ## Crash recovery preserves an interrupted turn diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 3b9111f558..a7ccd20902 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -458,7 +458,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise', - jsDoc: '/**\n * Durably persist a batch of events (called from the write-behind drain at\n * the `session/flush` checkpoint). Honors the append-only and contiguous-seq\n * contracts: the first event\'s `seq` MUST equal the stored next-seq (after\n * `load` has durably closed any interrupted turn). Rejects non-JSON-\n * serializable `event.data` with an error naming the offending event type.\n * @param id - the session the batch belongs to.\n * @param events - the contiguous batch to persist, in seq order.\n */', + jsDoc: '/**\n * Durably persist a batch of events. Honors the append-only and contiguous-\n * seq contracts: the first event\'s `seq` MUST equal the stored next-seq\n * (after `load` has durably closed any interrupted turn). Rejects non-JSON-\n * serializable `event.data` with an error naming the offending event type.\n * @param id - the session the batch belongs to.\n * @param events - the contiguous batch to persist, in seq order.\n */', }, { signature: 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index f6fff4fa42..7abf4fb206 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -40,7 +40,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the ## Write path -The plugin buffers frozen session events and drains them on flush or disposal. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. Operations for one session are serialized; disposal waits for initialization and the final drain so no write lands after teardown. +The plugin copies frozen session events into one controller per live session and starts an eager drain. Concurrent events share the current write; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. Operations for one session are serialized; disposal drains every retained controller before teardown. ## Model Experience diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 39619ff60a..82971a17e9 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -31,7 +31,7 @@ interface Config { ## Write path -Like the JSONL backend, the plugin also installs the `session/event` → buffer → `session/flush` drain: it copies each already-frozen event into a persistence-owned buffer, persists a fork's seed once on `session/created`, keeps a per-session write cursor so a resumed session never re-appends stored events, and seeds existing live sessions on apply (HMR does not replay `session/created`). Dispose awaits every in-flight init + final drain and then closes the database, so no write lands after teardown. +Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session and starts an eager drain. Concurrent events share the current transaction; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database. ## Model Experience diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 045686ba5b..32097a7108 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -10,7 +10,7 @@ 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 (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | +| `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 }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | | `list(): Promise` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | @@ -23,11 +23,11 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l ## The write coordinator -`PersistenceCoordinator` owns per-id state, write-behind buffers and serialization, the `session/event` → `session/flush` drain, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its four public service methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). +`PersistenceCoordinator` owns per-id serialization, one eager write controller per live session, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its four public service methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) and [flush-controller simplification](../../../.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md). -The coordinator implements durability at checkpoints but does not select their schedule. Persisted deployments explicitly compose [`dsh-session-checkpoint-policy`](../session-checkpoint-policy) when they want request-, tool-dispatch-, and completed-step recovery boundaries; omitting it leaves the loop's coarser checkpoints intact. +Each `session/event` copies its event into the session controller and starts an eager drain without blocking the producer. Concurrent notifications share the current drain; events admitted during a write remain pending and trigger the next batch. `session/flush` is an observation barrier that waits until the controller has no current or pending batch. An eager failure is logged and retains the batch; the next explicit flush or backend teardown retries it and surfaces failure to its caller. -When a live session emits `session/disposed`, the coordinator waits for its initialization, serializes a final buffer drain, then releases every map entry owned by that exact `Session` object. A failed final drain keeps the pending buffer for backend teardown to retry. Backend teardown stops event admission first, awaits all in-flight session retirements and remaining per-id operations, drains any retained buffers, and only then closes the storage handle. +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. The side-effect-free `locate` query remains backend-owned because it describes storage topology rather than write orchestration. diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 7dc0c9a0ba..e1b604e807 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -99,6 +99,13 @@ interface SessionState { owner?: Session } +/** One live session's initialization and eager write-behind controller. */ +interface LiveSessionState { + pending: SessionEvent[] + init: Promise + flush: Promise | undefined +} + /** Collect the rejection reasons from a set of promises (none-throwing). */ async function settledErrors(promises: Iterable>): Promise { const settled = await Promise.allSettled([...promises]) @@ -153,21 +160,13 @@ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): export class PersistenceCoordinator { /** Backend bookkeeping keyed by session id (NOT the live Session object). */ private states = new Map() - /** Write-behind buffers keyed by the live Session (write path). */ - private buffers = new Map() + /** Lifecycle and write-behind state keyed by the exact live Session. */ + private live = new Map() /** * Per-session serialization: every operation chains onto the prior one for the * same id, so writes for one session never interleave. Keyed by session id. */ private chains = new Map>() - /** - * Init promises keyed by live session object, preventing an id-reusing - * replacement from inheriting stale initialization. Flush is the public - * observation boundary; callers do not inspect this bookkeeping directly. - */ - private inits = new Map>() - /** Final drains started by fire-and-forget session disposal notifications. */ - private retirements = new Set>() constructor(private ctx: Context, private backend: PersistenceBackend) { this.installWritePath() @@ -290,7 +289,7 @@ export class PersistenceCoordinator { * public methods must NOT call each other (deadlock); they call the unserialized * `*Core` helpers instead. */ - private serialize(id: SessionId, op: () => Promise): Promise { + private serialize(id: SessionId, op: () => Promise | T): Promise { const prior = this.chains.get(id) ?? Promise.resolve() const next = prior.then(op, op) // Keep the chain alive but swallow this op's rejection for the NEXT waiter @@ -331,15 +330,10 @@ export class PersistenceCoordinator { // reverse registration order, so event admission closes before this final // drain reaches quiescence and closes the backend. ctx.effect(() => async () => { - await this.awaitRetirements() - let disposeError: unknown try { - const errors = [ - ...await settledErrors(this.inits.values()), - ...await settledErrors([...this.buffers.keys()].map(s => this.flush(s))), - ...await settledErrors(this.chains.values()), - ] + const errors = await settledErrors([...this.live.keys()].map(session => this.flushForDispose(session))) + while (this.chains.size > 0) await Promise.allSettled([...this.chains.values()]) if (errors.length > 0) { throw new AggregateError(errors, `${this.backend.name} dispose failed`) } @@ -360,25 +354,20 @@ export class PersistenceCoordinator { } }, `${this.backend.name} write path`) - // Capture the header on creation; persist a fork's seed once. Record the init - // promise so flush/dispose can await it (onCreated is async). + // Capture the header on creation and persist a fork's seed once. ctx.on('session/created', (session) => { void this.initFor(session) }) - // Session emits an owned frozen event. Keep a persistence-owned copy anyway - // so the write-behind queue owns exactly the record it will flush rather than - // retaining a product-layer record by identity. Serializability is guaranteed - // at the source, so structuredClone is safe. + // Keep a persistence-owned copy of each frozen event and start an eager drain. ctx.on('session/event', (session, event) => { - let buffer = this.buffers.get(session) - if (!buffer) this.buffers.set(session, buffer = []) - buffer.push(structuredClone(event)) + const live = this.initFor(session) + live.pending.push(structuredClone(event)) + if (live.flush === undefined) this.scheduleDrain(session, live) }) - // Drain to the backend at the durability checkpoint. + // Callers use flush as the observation barrier for the eager write path. ctx.on('session/flush', session => this.flush(session)) - // Session disposal is observe-only, so the coordinator observes the - // detached task itself and backend teardown awaits quiescence. + // Session disposal is observe-only, so retirement contains its own failure. ctx.on('session/disposed', (session) => { this.retire(session) }) // HMR: a hot reload does not replay session/created, so seed existing live @@ -386,52 +375,33 @@ export class PersistenceCoordinator { for (const session of ctx.sessions.list()) void this.initFor(session) } - /** Start, observe, and track one disposed session's final drain. */ + /** Start and observe one disposed session's final drain. */ private retire(session: Session): void { - const task = this.retireCore(session) - this.retirements.add(task) - const settled = (): void => { this.retirements.delete(task) } - void task.then(settled, (error: unknown) => { - settled() + void this.retireCore(session).catch((error: unknown) => { this.ctx.logger.warn(`${this.backend.name}: session "${session.id}" retirement failed: ${String(error)}`) }) } /** Drain and release state owned by one exact disposed Session lifecycle. */ private async retireCore(session: Session): Promise { - await this.inits.get(session) - + await this.flush(session) const id = session.header.id - await this.serialize(id, async () => { - await this.drain(session) - this.buffers.delete(session) - this.inits.delete(session) + await this.serialize(id, () => { + this.live.delete(session) if (this.states.get(id)?.owner === session) this.states.delete(id) }) } - /** Await every retirement admitted before listener teardown. */ - private async awaitRetirements(): Promise { - while (this.retirements.size > 0) { - await Promise.allSettled([...this.retirements]) - } - } - - /** Start (once) the async init for a session and remember its promise. */ - private initFor(session: Session): Promise { - const existing = this.inits.get(session) + /** Return the one lifecycle controller for a live session, creating it if needed. */ + private initFor(session: Session): LiveSessionState { + const existing = this.live.get(session) if (existing) return existing - // Snapshot the seed SYNCHRONOUSLY — initFor runs inside the `session/created` - // emit, before any later append invalidates the public array snapshot. Events - // are already frozen; cloning gives persistence independent ownership. const seed = session.events.map(e => structuredClone(e)) - const p = this.onCreated(session, seed) - // Attach a no-op rejection handler so a failing init does not surface as an - // unhandled rejection if no flush observes `p` before it rejects. The REAL - // error is still delivered: flush/dispose await the same `p` from the map. - p.catch(() => { /* observed by flush/dispose via the stored promise */ }) - this.inits.set(session, p) - return p + const live: LiveSessionState = { pending: [], init: Promise.resolve(), flush: undefined } + this.live.set(session, live) + live.init = this.serialize(session.header.id, () => this.onCreated(session, seed)) + live.init.catch(() => { /* observed by flush/dispose through the controller */ }) + return live } /** @@ -452,7 +422,7 @@ export class PersistenceCoordinator { * * Cases, by whether this backend tracks the id and whether an artifact exists: * 1. Already tracked → no-op (or claim ownerless state if the seed matches, - * or reclaim a truly-abandoned id, else reject as a collision). + * else reject as a collision). * 2. Not tracked, an artifact EXISTS at this cwd and is a seq-aligned PREFIX * of the live events → ADOPT it (HMR/reload), persisting any live suffix. * 3. Not tracked, an artifact EXISTS but is NOT a prefix → REJECT (collision). @@ -487,17 +457,10 @@ export class PersistenceCoordinator { // Persist the seed SUFFIX beyond the persisted prefix. Constructor seed // events never emit session/event, so the buffer never sees them. const suffix = seed.slice(tracked.cursor) - if (suffix.length > 0) await this.append(id, suffix) + if (suffix.length > 0) await this.appendCore(id, suffix) return } - // Owned by a DIFFERENT live session. Reclaim ONLY a truly-abandoned id - // (never materialized, no pending buffer); else it is a real collision. - const ownerBuffer = this.buffers.get(tracked.owner) - if (!tracked.materialized && !ownerBuffer?.length) { - this.states.delete(id) - } else { - throw new Error(`session "${id}" is already bound to a different live session in this backend (id collision)`) - } + throw new Error(`session "${id}" is already bound to a different live session in this backend (id collision)`) } // case 2/3: an artifact at THIS cwd is adopted as a live prefix (or rejected @@ -509,20 +472,20 @@ export class PersistenceCoordinator { // Do NOT route through loadCore(): that crash-repairs open turns as // interrupted, which is wrong for HMR while the live Session is still the // authority and may append the real step/turn end later. - await this.serialize(id, () => this.adoptLivePrefix(session, seed, live)) + await this.adoptLivePrefix(session, seed, live) return } // case 4: a genuinely new session. Register its meta (lazy), then persist its // seed (events present at creation time) once. const meta: SessionHeader = { ...session.header } - await this.create(meta) + await this.createCore(meta) // Bind this state to the live session so a later DIFFERENT session reusing // the id is detected as a collision (case 1) rather than silently no-opped. const created = this.states.get(id) /* v8 ignore next -- create() always sets the state for the id */ if (created !== undefined) created.owner = session - if (seed.length > 0) await this.append(id, seed) + if (seed.length > 0) await this.appendCore(id, seed) } /** @@ -551,36 +514,48 @@ export class PersistenceCoordinator { } private async flush(session: Session): Promise { - // Wait for the session's init (onCreated) so the state/cursor and any - // fork-seed persistence are in place before draining. Awaiting the same - // promise initFor stored also surfaces an init failure (e.g. a collision) - // here, where the caller of session/flush observes it. - await this.inits.get(session) - // Serialize the WHOLE drain (read cursor → append → splice) on the per-session - // chain so two concurrent flushes cannot both read the same cursor and - // seq-mismatch on the second append. - await this.serialize(session.header.id, () => this.drain(session)) + const live = this.initFor(session) + await live.init + while (live.flush !== undefined || live.pending.length > 0) { + await this.ensureFlush(session, live) + } } - /** Drain a session's write buffer to the backend. Caller serializes this per id. */ - private async drain(session: Session): Promise { - const buffer = this.buffers.get(session) - if (!buffer?.length) return - // Copy WITHOUT removing: the buffer is the only durable-pending copy of these - // events. Drain it only AFTER the append commits; events pushed during the - // await sit past batch.length and survive the prefix splice, so a - // retry/dispose re-drains the rest. - const batch = buffer.slice() - const state = this.states.get(session.header.id) - // Only append events at or beyond the write cursor (a resumed session's seed - // is already stored). flush awaits the init above, which always sets state, - // so the `?? 0` fallback is a defensive guard that never fires in practice. + /** Let an eager attempt settle, then make one teardown-owned retry observable. */ + private async flushForDispose(session: Session): Promise { + const current = this.live.get(session)?.flush + if (current !== undefined) await Promise.allSettled([current]) + await this.flush(session) + } + + /** Start an eager drain without exposing its failure to the synchronous append. */ + private scheduleDrain(session: Session, live: LiveSessionState): void { + void this.ensureFlush(session, live).catch((error: unknown) => { + this.ctx.logger.warn(`${this.backend.name}: eager drain for session "${session.id}" failed (buffered events retained): ${String(error)}`) + }) + } + + /** Return the current drain, or start one for the complete pending batch. */ + private ensureFlush(session: Session, live: LiveSessionState): Promise { + if (live.flush !== undefined) return live.flush + const flush = live.init + .then(() => this.serialize(session.header.id, () => this.drain(session.header.id, live))) + .finally(() => { live.flush = undefined }) + live.flush = flush + void flush.then(() => { + if (live.pending.length > 0) this.scheduleDrain(session, live) + }, () => {}) + return flush + } + + /** Drain one stable prefix; events admitted during the write remain pending. */ + private async drain(id: SessionId, live: LiveSessionState): Promise { + const batch = live.pending.slice() + const state = this.states.get(id) /* v8 ignore next -- state is always set by the awaited init before flush */ const cursor = state?.cursor ?? 0 const fresh = batch.filter(e => e.seq >= cursor) - // appendCore (NOT the serialized append) — drain already runs inside the - // per-session chain, so re-entering via append() would deadlock. - if (fresh.length > 0) await this.appendCore(session.header.id, fresh) - buffer.splice(0, batch.length) + await this.appendCore(id, fresh) + live.pending.splice(0, batch.length) } } diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 3c102e6ede..eec0292a10 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -63,10 +63,9 @@ export abstract class SessionPersistence extends Service { abstract create(meta: SessionHeader): Promise /** - * Durably persist a batch of events (called from the write-behind drain at - * the `session/flush` checkpoint). Honors the append-only and contiguous-seq - * contracts: the first event's `seq` MUST equal the stored next-seq (after - * `load` has durably closed any interrupted turn). Rejects non-JSON- + * Durably persist a batch of events. Honors the append-only and contiguous- + * seq contracts: the first event's `seq` MUST equal the stored next-seq + * (after `load` has durably closed any interrupted turn). Rejects non-JSON- * serializable `event.data` with an error naming the offending event type. * @param id - the session the batch belongs to. * @param events - the contiguous batch to persist, in seq order. diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 510ae38136..4b0a93134f 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -48,10 +48,8 @@ interface MemoryConfig { store?: MemoryStore } /** Test-only view of the coordinator containers whose retirement is the contract under test. */ interface CoordinatorInternals { states: Map - buffers: Map + live: Map | undefined }> chains: Map - inits: Map - retirements: Set> } /** @@ -204,6 +202,40 @@ runCoordinatorContract('memory', async (): Promise => { } }) +describe('PersistenceCoordinator eager writes', () => { + it('starts a follow-up batch for events admitted during an in-flight write', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const appendGate = Promise.withResolvers() + backend.beforeAppend = async (attempt) => { + if (attempt === 1) await appendGate.promise + } + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const session = ctx.sessions.create(SessionId('eager-follow-up')) + await ctx.sessions.flush(session) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) + + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + appendGate.resolve(true) + + await vi.waitFor(() => { + expect(backend.appendAttempts).toBe(2) + expect(backend.store.get(session.id)?.events.map(event => event.seq)).toEqual([0, 1]) + }) + } finally { + appendGate.resolve(true) + await fiber.dispose() + await ctx.fiber.dispose() + } + }) +}) + describe('PersistenceCoordinator retirement', () => { it('a retiring unmaterialized owner without buffered events releases its id', async () => { const ctx = new Context() @@ -233,7 +265,6 @@ describe('PersistenceCoordinator retirement', () => { await ctx.plugin(Object.assign((inner: Context) => { reuse = inner.sessions.create(id) }, { inject: ['sessions'] })) - await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 2) }) loadGate.resolve(true) await expect(blockingLoad).rejects.toThrow(/not found/) @@ -275,10 +306,11 @@ describe('PersistenceCoordinator retirement', () => { await ctx.plugin(Object.assign((inner: Context) => { reuse = inner.sessions.create(id) }, { inject: ['sessions'] })) - await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/bound to a different live session/) + const reuseFlush = ctx.sessions.flush(reuse) loadGate.resolve(true) await expect(blockingLoad).rejects.toThrow(/not found/) + await expect(reuseFlush).rejects.toThrow(/id collision/) await vi.waitFor(() => { expect(backend.store.get(id)?.events.map(event => event.seq)).toEqual([0, 1]) }) @@ -346,8 +378,9 @@ describe('PersistenceCoordinator retirement', () => { coordinator = new PersistenceCoordinator(inner, backend) }, { inject: ['sessions'] })) const internals = coordinator as unknown as CoordinatorInternals - backend.beforeAppend = async (attempt) => { - if (attempt === 1) { + let retryEnabled = false + backend.beforeAppend = async () => { + if (!retryEnabled) { backend.lifecycle.push('append-failed') throw new Error('transient append failure') } @@ -364,17 +397,18 @@ describe('PersistenceCoordinator retirement', () => { await sessionFiber.dispose() await vi.waitFor(() => { - expect(backend.appendAttempts).toBe(1) - expect(internals.retirements.size).toBe(0) + expect(backend.appendAttempts).toBeGreaterThanOrEqual(1) + expect([...internals.live.values()][0]?.pending).toEqual(expect.arrayContaining([ + expect.objectContaining({ seq: 0 }), + expect.objectContaining({ seq: 1 }), + ])) }) - expect([...internals.buffers.values()]).toEqual([expect.arrayContaining([ - expect.objectContaining({ seq: 0 }), - expect.objectContaining({ seq: 1 }), - ])]) + retryEnabled = true await backendFiber.dispose() expect(backend.store.get(SessionId('retry-retirement'))?.events.map(event => event.seq)).toEqual([0, 1]) - expect(backend.lifecycle).toEqual(['append-failed', 'append-committed', 'close']) + expect(backend.lifecycle.at(-2)).toBe('append-committed') + expect(backend.lifecycle.at(-1)).toBe('close') } finally { await backendFiber.dispose() await ctx.fiber.dispose() @@ -407,7 +441,8 @@ describe('PersistenceCoordinator retirement', () => { await sessionFiber.dispose() await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) - expect(internals.retirements.size).toBe(1) + expect(internals.live.size).toBe(1) + expect([...internals.live.values()][0]?.flush).toBeInstanceOf(Promise) }) let disposed = false @@ -426,6 +461,47 @@ describe('PersistenceCoordinator retirement', () => { await ctx.fiber.dispose() } }) + + it('backend teardown waits for a detached public append before close', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + const appendGate = Promise.withResolvers() + backend.beforeAppend = async () => { + backend.lifecycle.push('append-started') + await appendGate.promise + backend.lifecycle.push('append-committed') + } + + try { + const id = SessionId('inflight-public-append') + await coordinator.create(meta(id)) + const append = coordinator.append(id, [{ + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + }]) + await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) + + let disposed = false + const teardown = fiber.dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + + appendGate.resolve(true) + await Promise.all([append, teardown]) + expect(backend.lifecycle).toEqual(['append-started', 'append-committed', 'close']) + } finally { + appendGate.resolve(true) + await fiber.dispose() + await ctx.fiber.dispose() + } + }) }) describe('SessionPersistence service registration', () => { @@ -555,11 +631,9 @@ describe('SessionPersistence service registration', () => { expect(ctx.sessions.list()).toHaveLength(0) expect({ states: coordinator.states.size, - buffers: coordinator.buffers.size, + live: coordinator.live.size, chains: coordinator.chains.size, - inits: coordinator.inits.size, - retirements: coordinator.retirements.size, - }).toEqual({ states: 0, buffers: 0, chains: 0, inits: 0, retirements: 0 }) + }).toEqual({ states: 0, live: 0, chains: 0 }) }) } finally { await fiber.dispose() From 7f5ba286fe5a23d9ddf5a09ec07cb04414daad3f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 23 Jul 2026 18:31:37 +0800 Subject: [PATCH 2/7] fix: keep crash repair away from live sessions --- ...collapse-persistence-flush-state.i18n.yaml | 4 +- ...-07-23-collapse-persistence-flush-state.md | 6 ++ ...-23-collapse-persistence-flush-state.zh.md | 6 ++ docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/persistence.md | 2 + .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent-loop/tests/resume.spec.ts | 21 ++++++ .../session-persistence/README.md | 4 +- .../session-persistence/src/coordinator.ts | 14 ++++ .../session-persistence/src/index.ts | 8 +- .../tests/coordinator-contract.ts | 45 +++++++++++ .../tests/persistence.spec.ts | 75 +++++++++++++------ 12 files changed, 161 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml index 117e2b7202..4c0f54d546 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.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 -2026-07-23-collapse-persistence-flush-state.md: 69403fe3c2ee556cb10593fd43857e0d844242df -2026-07-23-collapse-persistence-flush-state.zh.md: 0b38ee26b9e6273672cbc218826c0dc399158a71 +2026-07-23-collapse-persistence-flush-state.md: 9a2de00b2ad0c2417b6cdcba9cbc4f020b93037d +2026-07-23-collapse-persistence-flush-state.zh.md: f9152989fef79efbe0afc27d256d8d3418ce84ba diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md index 69403fe3c2..9a2de00b2a 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md @@ -16,6 +16,8 @@ Each live `Session` has one controller containing `pending`, `init`, and the opt Initialization now enters the existing per-id operation chain once and calls the unserialized core operations while it owns that turn. The chain remains separate from the live controller because detached public `create`/`append`/`load` calls can race without a `Session` object and still require identity-level serialization. +Crash repair is cold-only. For a live identity, `load(id)` snapshots the authoritative in-memory header and events before awaiting their flush; it returns that durable snapshot when balanced and rejects an open turn without reading or repairing storage. A cold identity follows the stored-prefix repair path. HMR adoption remains separate through `loadLive` and truncates torn storage without closing the authoritative live turn. + The live-controller map is also the retirement registry. Successful retirement drains and removes its controller; failed retirement leaves it in the map. Backend teardown stops event admission, flushes every controller still present, awaits remaining per-id operations, and closes the backend. No separate retirement set is needed to rediscover unfinished work. ## Alternatives considered @@ -26,11 +28,15 @@ The live-controller map is also the retirement registry. Successful retirement d **Latch the first eager error permanently.** This makes every later flush deterministic, but prevents the existing teardown retry from recovering a transient storage failure. Retaining the batch without latching the error preserves both observability and retry. +**Reject every live load.** This is safe but removes established balanced live snapshots used by persistence consumers and tests. Snapshot-before-flush gives the call a stable linearization point: successful flush proves exactly that snapshot is durable, while the live path never invokes crash repair. + ## Verification - A focused coordinator test gates the first append, admits another event during that write, and observes an automatic second durable batch without calling `session/flush`. - The shared coordinator contract still covers live adoption, collisions, crash repair, and session/backend disposal over the in-memory, JSONL, and SQLite backends. - Failure and teardown tests keep rejected batches pending, retry them before close, and prove an in-flight controller delays backend close. +- The shared backend contract persists an open live turn, proves `load` rejects without writing synthetic closers, completes and retires the owner, then reloads the exact completed turn. +- An AgentLoop regression races `resume()` against a live open turn and proves the original agent can still durably complete it without an injected `interrupted` boundary. ## Consequences diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md index 0b38ee26b9..f9152989fe 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md @@ -16,6 +16,8 @@ Status: implemented 初始化只进入现有的按 id 操作链一次,并在占有该轮执行权时调用未串行化的核心操作。该操作链与活跃控制器保持分离,因为公共 `create`、`append`、`load` 调用即使没有 `Session` 对象仍可能发生竞态,依然需要按标识串行执行。 +崩溃修复仅适用于冷态标识。对于活跃标识,`load(id)` 会在等待刷新完成前,先对内存中的权威标头和事件生成快照;若快照闭合,则返回这个已持久化的快照;若轮次仍打开,则在不读取或修复存储的情况下拒绝该次加载。冷态标识沿用已存储前缀的修复路径。HMR 接管仍通过 `loadLive` 独立处理,会截断撕裂的存储,但不会闭合权威的活跃轮次。 + 活跃控制器映射同时也是退役注册表。退役成功时,系统排空并移除其控制器;退役失败时,控制器保留在映射中。后端资源销毁会停止接纳事件,刷新所有仍存在的控制器,等待其余按 id 操作完成,然后关闭后端。无需另设退役集合来重新发现未完成的工作。 ## 备选方案 @@ -26,11 +28,15 @@ Status: implemented **永久锁存首次即时写入错误。** 这会让后续每次刷新都得到确定的结果,却会阻止现有的资源销毁重试从暂时性存储故障中恢复。保留批次但不锁存错误,可以同时保留可观测性和重试能力。 +**拒绝对所有活跃会话的加载。** 这样做很安全,但会让持久化消费方和测试无法再使用既有的闭合活跃会话快照。先生成快照再刷新,为调用提供了稳定的线性化点:刷新成功即可证明正是该快照已持久化,而活跃路径绝不调用崩溃修复。 + ## 验证 - 一个针对协调器的测试会阻塞第一次追加,在该次写入期间接纳另一个事件,并在不调用 `session/flush` 的情况下观测到自动执行的第二个持久批次。 - 共享协调器契约仍覆盖内存、JSONL 和 SQLite 后端上的活跃会话接管、冲突、崩溃修复,以及会话和后端的资源释放。 - 失败和资源销毁测试会让写入失败的批次保持待处理,在关闭前重试这些批次,并证明尚在执行的控制器会延迟后端关闭。 +- 共享后端契约会持久化一个仍打开的活跃轮次,证明 `load` 会拒绝且不会写入合成闭合事件,随后完成该轮次并让其所有者退役,最后重新加载完全相同的已完成轮次。 +- AgentLoop 回归测试让 `resume()` 与一个仍打开的活跃轮次发生竞态,并证明原有的 agent(智能体)仍能完成该轮次并将其持久化,其间不会注入 `interrupted` 边界。 ## 后果 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 67b4ecbd9e..0e3e773006 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -917,7 +917,9 @@ abstract append(id: SessionId, events: readonly SessionEvent[]): Promise * Load a header and balanced contiguous log. A complete interrupted final * turn is preserved and durably closed with missing tool errors plus any open * step and turn boundaries; only a torn final record is discarded. Unknown - * versions and corruption in the committed prefix reject. + * versions and corruption in the committed prefix reject. Implementations + * MUST NOT crash-repair an identity still bound to a live Session: a balanced + * live log may return as a durable snapshot, while an open live turn rejects. * @param id - the persisted session to reload. * @returns the header and a log ending on a balanced `turn/end`. */ diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 9327c2e4dd..03350623f8 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -12,6 +12,8 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end`. It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }`, keeping the log balanced and the turn-enclosure invariant intact. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)). +Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id)` snapshots the in-memory log, waits until that snapshot is durable, and returns it only when balanced; an open live turn rejects rather than receiving synthetic interruption boundaries. HMR also adopts a live prefix without closing its active turn. + ## `SessionLocation` — optional per-session artifact target `SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 8c39e81b7d..c04fcb7891 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -462,7 +462,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', + jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject. Implementations\n * MUST NOT crash-repair an identity still bound to a live Session: a balanced\n * live log may return as a durable snapshot, while an open live turn rejects.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', }, { signature: 'abstract list(): Promise', diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 5c64bb92f9..fdf5c39514 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -111,6 +111,27 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.fiber.dispose() }) + it('resume cannot crash-repair a turn owned by a live agent', async () => { + const { ctx } = await persistentHarness(new MockAdapter([textResponse('unused')])) + const sessionId = SessionId('live-resume-race') + const first = (await ctx.agents.create({ sessionId })).agent + first.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await ctx.sessions.flush(first.session) + + await expect(ctx.agents.resume({ resumeSessionId: sessionId })) + .rejects.toThrow(/live turn is open/) + + first.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.sessions.flush(first.session) + const loaded = await ctx.sessionPersistence.load(sessionId) + expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) + expect(loaded.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'completed' } }, + }) + await ctx.fiber.dispose() + }) + it('createAgent works without meta (no cwd)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 32097a7108..a09dbcb426 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -11,7 +11,7 @@ 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 }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | +| `load(id): Promise<{ meta; events }>` | Return a flushed balanced snapshot for a live session, rejecting while its turn is open; 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. | | `list(): Promise` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | ## Invariants every backend must honor @@ -27,6 +27,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l Each `session/event` copies its event into the session controller and starts an eager drain without blocking the producer. Concurrent notifications share the current drain; events admitted during a write remain pending and trigger the next batch. `session/flush` is an observation barrier that waits until the controller has no current or pending batch. An eager failure is logged and retains the batch; the next explicit flush or backend teardown retries it and surfaces failure to its caller. +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 only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. A cold id follows storage repair normally. HMR adoption likewise uses the separate `loadLive` hook and never closes the active turn. + 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. The side-effect-free `locate` query remains backend-owned because it describes storage topology rather than write orchestration. diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index e1b604e807..602ac43f72 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -256,6 +256,8 @@ export class PersistenceCoordinator { * @returns the header plus the event log, ending on a balanced `turn/end`. */ load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const live = this.ctx.sessions.get(id) + if (live !== undefined) return this.loadLiveSnapshot(live) return this.serialize(id, () => this.loadCore(id)) } @@ -279,6 +281,18 @@ export class PersistenceCoordinator { return { meta, events: balanced } } + /** Return a durable balanced live snapshot without applying cold crash repair. */ + private async loadLiveSnapshot(session: Session): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const meta = structuredClone(session.header) + const events = session.events.map(event => structuredClone(event)) + await this.flush(session) + if (events.length === 0) throw new Error(`session "${session.id}" not found`) + if (interruptedTurnClosers(events).length > 0) { + throw new Error(`cannot load session "${session.id}" while its live turn is open; use the live Session or wait for the turn to close`) + } + return { meta, events } + } + // Listing is a direct backend read and needs no coordinator state. // --- per-id serialization + adoption helpers --- diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index eec0292a10..8490b133bd 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -74,9 +74,11 @@ export abstract class SessionPersistence extends Service { /** * Load a header and balanced contiguous log. A complete interrupted final - * turn is preserved and durably closed with missing tool errors plus any open - * step and turn boundaries; only a torn final record is discarded. Unknown - * versions and corruption in the committed prefix reject. + * turn is preserved and durably closed with missing tool errors plus any open + * step and turn boundaries; only a torn final record is discarded. Unknown + * versions and corruption in the committed prefix reject. Implementations + * MUST NOT crash-repair an identity still bound to a live Session: a balanced + * live log may return as a durable snapshot, while an open live turn rejects. * @param id - the persisted session to reload. * @returns the header and a log ending on a balanced `turn/end`. */ diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 620d069d32..a4d6b16072 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -88,6 +88,51 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('rejects crash-repair load while a live session owns the persisted prefix', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + let session!: Session + const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { + session = inner.sessions.create(SessionId('live-load'), { meta: { cwd: WORK } }) + }, { inject: ['sessions'] })) + try { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await ctx.sessions.flush(session) + + await expect(ctx.sessionPersistence.load(session.id)) + .rejects.toThrow(`cannot load session "${session.id}" while its live turn is open`) + + send(session, oneTurnLog().slice(1)) + await ctx.sessions.flush(session) + await sessionFiber.dispose() + + await vi.waitFor(async () => { + const loaded = await ctx.sessionPersistence.load(session.id) + expect(loaded.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + expect(loaded.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'completed' } }, + }) + }) + } finally { + await sessionFiber.dispose() + await fiber.dispose() + await fix.cleanup() + } + }) + + it('does not load an unmaterialized empty live session', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const session = ctx.sessions.create(SessionId('empty-live'), { meta: { cwd: WORK } }) + await expect(ctx.sessionPersistence.load(session.id)).rejects.toThrow(/not found/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + it('round-trips the seed boundary (seedLength) through persistence', async () => { // A forked child records how many leading events were inherited via the seed; the // boundary must survive a reload (so a resume/replay can tell the inherited prefix from diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 4b0a93134f..7c15d52a4a 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -245,7 +245,6 @@ describe('PersistenceCoordinator retirement', () => { const backendFiber = await ctx.plugin(Object.assign((inner: Context) => { coordinator = new PersistenceCoordinator(inner, backend) }, { inject: ['sessions'] })) - const loadGate = Promise.withResolvers() try { const id = SessionId('retiring-lazy-owner') @@ -254,52 +253,42 @@ describe('PersistenceCoordinator retirement', () => { first = inner.sessions.create(id) }, { inject: ['sessions'] })) await ctx.sessions.flush(first) - - const baselineLoads = backend.loadAttempts - backend.beforeLoadStored = async () => { await loadGate.promise } - const blockingLoad = coordinator.load(id) - await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 1) }) await firstFiber.dispose() + const internals = coordinator as unknown as CoordinatorInternals + await vi.waitFor(() => { expect(internals.states.has(id)).toBe(false) }) let reuse!: Session await ctx.plugin(Object.assign((inner: Context) => { reuse = inner.sessions.create(id) }, { inject: ['sessions'] })) - loadGate.resolve(true) - await expect(blockingLoad).rejects.toThrow(/not found/) await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined() } finally { - loadGate.resolve(true) await backendFiber.dispose() await ctx.fiber.dispose() } }) - it('a retiring owner with buffered events still rejects same-id reuse', async () => { + it('a replacement queued before retirement cleanup still collides with the live owner', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const backend = new ControlledBackend() - let coordinator!: PersistenceCoordinator const backendFiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) + new PersistenceCoordinator(inner, backend) }, { inject: ['sessions'] })) - const loadGate = Promise.withResolvers() + const appendGate = Promise.withResolvers() try { - const id = SessionId('retiring-buffered-owner') + const id = SessionId('retiring-live-owner') let first!: Session const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { first = inner.sessions.create(id) }, { inject: ['sessions'] })) await ctx.sessions.flush(first) + backend.beforeAppend = async () => { await appendGate.promise } first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - - const baselineLoads = backend.loadAttempts - backend.beforeLoadStored = async () => { await loadGate.promise } - const blockingLoad = coordinator.load(id) - await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 1) }) + await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) await firstFiber.dispose() let reuse!: Session @@ -308,14 +297,56 @@ describe('PersistenceCoordinator retirement', () => { }, { inject: ['sessions'] })) const reuseFlush = ctx.sessions.flush(reuse) - loadGate.resolve(true) - await expect(blockingLoad).rejects.toThrow(/not found/) + appendGate.resolve(true) + await expect(reuseFlush).rejects.toThrow(/bound to a different live session/) + expect(backend.store.get(id)?.events.map(event => event.seq)).toEqual([0, 1]) + } finally { + appendGate.resolve(true) + await backendFiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('a racing cold load survives retirement cleanup and rejects same-id reuse', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + let coordinator!: PersistenceCoordinator + const backendFiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + const appendGate = Promise.withResolvers() + + try { + const id = SessionId('retiring-buffered-owner') + let first!: Session + const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { + first = inner.sessions.create(id) + }, { inject: ['sessions'] })) + await ctx.sessions.flush(first) + backend.beforeAppend = async () => { await appendGate.promise } + first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) + await firstFiber.dispose() + const coldLoad = coordinator.load(id) + + let reuse!: Session + await ctx.plugin(Object.assign((inner: Context) => { + reuse = inner.sessions.create(id) + }, { inject: ['sessions'] })) + const reuseFlush = ctx.sessions.flush(reuse) + + appendGate.resolve(true) + await expect(coldLoad).resolves.toMatchObject({ + events: [{ seq: 0 }, { seq: 1 }], + }) await expect(reuseFlush).rejects.toThrow(/id collision/) await vi.waitFor(() => { expect(backend.store.get(id)?.events.map(event => event.seq)).toEqual([0, 1]) }) } finally { - loadGate.resolve(true) + appendGate.resolve(true) await backendFiber.dispose() await ctx.fiber.dispose() } From d2fcf385e8552854e67434ef0e0ee3467b7a0e0d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 23 Jul 2026 19:27:37 +0800 Subject: [PATCH 3/7] test: cold-load persisted session metadata --- .../tests/coordinator-contract.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index a4d6b16072..857e3e5a91 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -140,9 +140,13 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { - const session = ctx.sessions.create(SessionId('forked-child'), { meta: { cwd: WORK, seedLength: 3 } }) + let session!: Session + const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { + session = inner.sessions.create(SessionId('forked-child'), { meta: { cwd: WORK, seedLength: 3 } }) + }, { inject: ['sessions'] })) send(session, oneTurnLog()) await ctx.sessions.flush(session) + await sessionFiber.dispose() const loaded = await ctx.sessionPersistence.load(SessionId('forked-child')) expect(loaded.meta.seedLength).toBe(3) @@ -159,11 +163,15 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { - const session = ctx.sessions.create(SessionId('delegated-child'), { - meta: { cwd: WORK, parentSession: SessionId('root'), delegationDepth: 2 }, - }) + let session!: Session + const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => { + session = inner.sessions.create(SessionId('delegated-child'), { + meta: { cwd: WORK, parentSession: SessionId('root'), delegationDepth: 2 }, + }) + }, { inject: ['sessions'] })) send(session, oneTurnLog()) await ctx.parallel('session/flush', session) + await sessionFiber.dispose() const loaded = await ctx.sessionPersistence.load(SessionId('delegated-child')) expect(loaded.meta.delegationDepth).toBe(2) From e5d16d5e58aba13dd835025fcc7b5546bf293d03 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 23 Jul 2026 19:55:52 +0800 Subject: [PATCH 4/7] fix: close eager persistence races --- .../session-persistence/src/coordinator.ts | 24 ++++----- .../tests/persistence.spec.ts | 54 ++++++++++++++++--- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 602ac43f72..46db6eee62 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -346,7 +346,7 @@ export class PersistenceCoordinator { ctx.effect(() => async () => { let disposeError: unknown try { - const errors = await settledErrors([...this.live.keys()].map(session => this.flushForDispose(session))) + const errors = await settledErrors([...this.live.keys()].map(session => this.flush(session))) while (this.chains.size > 0) await Promise.allSettled([...this.chains.values()]) if (errors.length > 0) { throw new AggregateError(errors, `${this.backend.name} dispose failed`) @@ -474,7 +474,12 @@ export class PersistenceCoordinator { if (suffix.length > 0) await this.appendCore(id, suffix) return } - throw new Error(`session "${id}" is already bound to a different live session in this backend (id collision)`) + const owner = this.live.get(tracked.owner) + if (!tracked.materialized && !owner?.pending.length) { + this.states.delete(id) + } else { + throw new Error(`session "${id}" is already bound to a different live session in this backend (id collision)`) + } } // case 2/3: an artifact at THIS cwd is adopted as a live prefix (or rejected @@ -530,18 +535,14 @@ export class PersistenceCoordinator { private async flush(session: Session): Promise { const live = this.initFor(session) await live.init + const overlapping = live.flush + if (overlapping !== undefined) await Promise.allSettled([overlapping]) while (live.flush !== undefined || live.pending.length > 0) { - await this.ensureFlush(session, live) + if (live.flush !== undefined) await live.flush + else await this.ensureFlush(session, live) } } - /** Let an eager attempt settle, then make one teardown-owned retry observable. */ - private async flushForDispose(session: Session): Promise { - const current = this.live.get(session)?.flush - if (current !== undefined) await Promise.allSettled([current]) - await this.flush(session) - } - /** Start an eager drain without exposing its failure to the synchronous append. */ private scheduleDrain(session: Session, live: LiveSessionState): void { void this.ensureFlush(session, live).catch((error: unknown) => { @@ -549,9 +550,8 @@ export class PersistenceCoordinator { }) } - /** Return the current drain, or start one for the complete pending batch. */ + /** Start one drain for the complete pending batch. */ private ensureFlush(session: Session, live: LiveSessionState): Promise { - if (live.flush !== undefined) return live.flush const flush = live.init .then(() => this.serialize(session.header.id, () => this.drain(session.header.id, live))) .finally(() => { live.flush = undefined }) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 7c15d52a4a..1ac6977134 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -234,6 +234,41 @@ describe('PersistenceCoordinator eager writes', () => { await ctx.fiber.dispose() } }) + + it('retries a failed overlapping eager write at the explicit flush barrier', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const appendGate = Promise.withResolvers() + backend.beforeAppend = async (attempt) => { + if (attempt === 1) { + await appendGate.promise + throw new Error('transient eager failure') + } + } + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const session = ctx.sessions.create(SessionId('eager-flush-retry')) + await ctx.sessions.flush(session) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) + + const barriers = [ctx.sessions.flush(session), ctx.sessions.flush(session)] + appendGate.resolve(true) + + await expect(Promise.all(barriers)).resolves.toEqual([undefined, undefined]) + expect(backend.appendAttempts).toBe(2) + expect(backend.store.get(session.id)?.events.map(event => event.seq)).toEqual([0, 1]) + } finally { + appendGate.resolve(true) + await fiber.dispose() + await ctx.fiber.dispose() + } + }) }) describe('PersistenceCoordinator retirement', () => { @@ -241,29 +276,32 @@ describe('PersistenceCoordinator retirement', () => { const ctx = new Context() await ctx.plugin(SessionStore) const backend = new ControlledBackend() - let coordinator!: PersistenceCoordinator const backendFiber = await ctx.plugin(Object.assign((inner: Context) => { - coordinator = new PersistenceCoordinator(inner, backend) + new PersistenceCoordinator(inner, backend) }, { inject: ['sessions'] })) + const loadGate = Promise.withResolvers() + backend.beforeLoadStored = async (attempt) => { + if (attempt === 1) await loadGate.promise + } try { const id = SessionId('retiring-lazy-owner') - let first!: Session const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { - first = inner.sessions.create(id) + inner.sessions.create(id) }, { inject: ['sessions'] })) - await ctx.sessions.flush(first) + await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) await firstFiber.dispose() - const internals = coordinator as unknown as CoordinatorInternals - await vi.waitFor(() => { expect(internals.states.has(id)).toBe(false) }) let reuse!: Session await ctx.plugin(Object.assign((inner: Context) => { reuse = inner.sessions.create(id) }, { inject: ['sessions'] })) + const reuseFlush = ctx.sessions.flush(reuse) - await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined() + loadGate.resolve(true) + await expect(reuseFlush).resolves.toBeUndefined() } finally { + loadGate.resolve(true) await backendFiber.dispose() await ctx.fiber.dispose() } From d0e5987c4a152294cbebe8ad4a1193298fd07416 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 23 Jul 2026 20:24:27 +0800 Subject: [PATCH 5/7] fix: serialize persistence ownership selection --- .../session-persistence/src/coordinator.ts | 11 ++++--- .../tests/coordinator-contract.ts | 33 +++++++++++++++++++ .../tests/persistence.spec.ts | 9 ++++- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 46db6eee62..18bd884acd 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -255,10 +255,13 @@ export class PersistenceCoordinator { * @param id - the persisted session to reload. * @returns the header plus the event log, ending on a balanced `turn/end`. */ - load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - const live = this.ctx.sessions.get(id) - if (live !== undefined) return this.loadLiveSnapshot(live) - return this.serialize(id, () => this.loadCore(id)) + async load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const selected = await this.serialize(id, async () => { + const live = this.ctx.sessions.get(id) + if (live !== undefined) return { live } + return { loaded: await this.loadCore(id) } + }) + return 'loaded' in selected ? selected.loaded : this.loadLiveSnapshot(selected.live) } private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 857e3e5a91..05b86c699d 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -121,6 +121,39 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('rechecks live ownership after a cold load enters the per-id chain', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const id = SessionId('queued-load-live-race') + const header = meta(id, WORK) + const start: SessionEvent = { + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + } + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(id, [start]) + + const loading = ctx.sessionPersistence.load(id) + const live = ctx.sessions.create(id, { seed: [start], meta: header }) + await expect(loading).rejects.toThrow(/live turn is open/) + + live.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.sessions.flush(live) + const loaded = await ctx.sessionPersistence.load(id) + expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) + expect(loaded.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'completed' } }, + }) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + it('does not load an unmaterialized empty live session', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 1ac6977134..8aa446ca56 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -354,6 +354,7 @@ describe('PersistenceCoordinator retirement', () => { coordinator = new PersistenceCoordinator(inner, backend) }, { inject: ['sessions'] })) const appendGate = Promise.withResolvers() + const loadGate = Promise.withResolvers() try { const id = SessionId('retiring-buffered-owner') @@ -367,15 +368,20 @@ describe('PersistenceCoordinator retirement', () => { first.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) }) await firstFiber.dispose() + const baselineLoads = backend.loadAttempts + backend.beforeLoadStored = async () => { await loadGate.promise } const coldLoad = coordinator.load(id) + appendGate.resolve(true) + await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 1) }) + let reuse!: Session await ctx.plugin(Object.assign((inner: Context) => { reuse = inner.sessions.create(id) }, { inject: ['sessions'] })) const reuseFlush = ctx.sessions.flush(reuse) - appendGate.resolve(true) + loadGate.resolve(true) await expect(coldLoad).resolves.toMatchObject({ events: [{ seq: 0 }, { seq: 1 }], }) @@ -385,6 +391,7 @@ describe('PersistenceCoordinator retirement', () => { }) } finally { appendGate.resolve(true) + loadGate.resolve(true) await backendFiber.dispose() await ctx.fiber.dispose() } From 4e5166828b6c91d528f698ae9c476398e337e0e6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 23 Jul 2026 20:58:20 +0800 Subject: [PATCH 6/7] fix: reserve cold loads across repair --- ...collapse-persistence-flush-state.i18n.yaml | 4 +- ...-07-23-collapse-persistence-flush-state.md | 3 +- ...-23-collapse-persistence-flush-state.zh.md | 3 +- docs/cordis-catalog/services.md | 2 + docs/core-data-structures/persistence.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../session-persistence/README.md | 2 +- .../session-persistence/src/coordinator.ts | 17 +++++- .../session-persistence/src/index.ts | 2 + .../tests/persistence.spec.ts | 57 +++++++++++++++++-- 10 files changed, 79 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml index 0d3801b6e1..9dd943ea72 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.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 -2026-07-23-collapse-persistence-flush-state.md: 3a7bc4d832d9c139614a8ecd85f7b29a6b5afe51 -2026-07-23-collapse-persistence-flush-state.zh.md: b99906f1051d2dc1369a92072d0e8b98402bba9f +2026-07-23-collapse-persistence-flush-state.md: 21e99b0fc37f97e441f6d92eb02636767cabf51e +2026-07-23-collapse-persistence-flush-state.zh.md: 0b11c45ed7d5291daed4726b9dd7fb8878d4a783 diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md index 3a7bc4d832..21e99b0fc3 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md @@ -16,7 +16,7 @@ Each live `Session` has one controller containing `pending`, `init`, and the opt Initialization now enters the existing per-id operation chain once and calls the unserialized core operations while it owns that turn. The chain remains separate from the live controller because detached public `create`/`append`/`load` calls can race without a `Session` object and still require identity-level serialization. -Crash repair is cold-only. For a live identity, `load(id)` snapshots the authoritative in-memory header and events before awaiting their flush; it returns that durable snapshot when balanced and rejects an open turn without reading or repairing storage. A cold identity follows the stored-prefix repair path. HMR adoption remains separate through `loadStored` plus the coordinator's cwd check and truncates torn storage without closing the authoritative live turn. +Crash repair is cold-only. For a live identity, `load(id)` snapshots the authoritative in-memory header and events before awaiting their flush; it returns that durable snapshot when balanced and rejects an open turn without reading or repairing storage. A cold load reserves its identity synchronously inside the per-id chain before awaiting stored-prefix reads or repair writes; the `session/created` publication boundary rejects and rolls back a same-id live session until the reservation clears. HMR adoption remains separate through `loadStored` plus the coordinator's cwd check and truncates torn storage without closing the authoritative live turn. The live-controller map is also the retirement registry. Successful retirement drains and removes its controller; failed retirement leaves it in the map. Backend teardown stops event admission, flushes every controller still present, awaits remaining per-id operations, and closes the backend. No separate retirement set is needed to rediscover unfinished work. @@ -37,6 +37,7 @@ The live-controller map is also the retirement registry. Successful retirement d - Failure and teardown tests keep rejected batches pending, retry them before close, and prove an in-flight controller delays backend close. - The shared backend contract persists an open live turn, proves `load` rejects without writing synthetic closers, completes and retires the owner, then reloads the exact completed turn. - An AgentLoop regression races `resume()` against a live open turn and proves the original agent can still durably complete it without an injected `interrupted` boundary. +- A controlled backend blocks `loadStored`, attempts same-id session publication while repair owns the reservation, and proves rollback leaves no ghost controller before a balanced resume succeeds. ## Consequences diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md index b99906f105..0b11c45ed7 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md @@ -16,7 +16,7 @@ Status: implemented 初始化只进入现有的按 id 操作链一次,并在占有该轮执行权时调用未串行化的核心操作。该操作链与活跃控制器保持分离,因为公共 `create`、`append`、`load` 调用即使没有 `Session` 对象仍可能发生竞态,依然需要按标识串行执行。 -崩溃修复仅适用于冷态标识。对于活跃标识,`load(id)` 会在等待刷新完成前,先对内存中的权威标头和事件生成快照;若快照闭合,则返回这个已持久化的快照;若轮次仍打开,则在不读取或修复存储的情况下拒绝该次加载。冷态标识沿用已存储前缀的修复路径。HMR 接管仍由 `loadStored` 与协调器的 cwd 检查独立处理,会截断撕裂的存储,但不会闭合权威的活跃轮次。 +崩溃修复仅适用于冷态标识。对于活跃标识,`load(id)` 会在等待刷新完成前,先对内存中的权威标头和事件生成快照;若快照闭合,则返回这个已持久化的快照;若轮次仍打开,则在不读取或修复存储的情况下拒绝该次加载。冷态加载会先在按 id 操作链内同步占用对应标识,再等待读取已存储前缀或执行修复写入;在这项占用解除前,`session/created` 发布边界会拒绝同 id 活跃会话的发布并将其回滚。HMR 接管仍由 `loadStored` 与协调器的 cwd 检查独立处理,会截断撕裂的存储,但不会闭合权威的活跃轮次。 活跃控制器映射同时也是退役注册表。退役成功时,系统排空并移除其控制器;退役失败时,控制器保留在映射中。后端资源销毁会停止接纳事件,刷新所有仍存在的控制器,等待其余按 id 操作完成,然后关闭后端。无需另设退役集合来重新发现未完成的工作。 @@ -37,6 +37,7 @@ Status: implemented - 失败和资源销毁测试会让写入失败的批次保持待处理,在关闭前重试这些批次,并证明尚在执行的控制器会延迟后端关闭。 - 共享后端契约会持久化一个仍打开的活跃轮次,证明 `load` 会拒绝且不会写入合成闭合事件,随后完成该轮次并让其所有者退役,最后重新加载完全相同的已完成轮次。 - AgentLoop 回归测试让 `resume()` 与一个仍打开的活跃轮次发生竞态,并证明原有的 agent(智能体)仍能完成该轮次并将其持久化,其间不会注入 `interrupted` 边界。 +- 一个受控后端会阻塞 `loadStored`,在修复操作持有标识占用期间尝试发布同 id 会话,并证明回滚不会留下残留控制器,之后可以成功恢复一个闭合会话。 ## 后果 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0f8d62d0b1..0cd92b806a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -920,6 +920,8 @@ abstract append(id: SessionId, events: readonly SessionEvent[]): Promise * versions and corruption in the committed prefix reject. Implementations * MUST NOT crash-repair an identity still bound to a live Session: a balanced * live log may return as a durable snapshot, while an open live turn rejects. + * A coordinator-backed cold load reserves the identity across storage awaits, + * so concurrent publication of a same-id live Session rejects. * @param id - the persisted session to reload. * @returns the header and a log ending on a balanced `turn/end`. */ diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index b44c1bfd57..0c2ed557de 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -12,7 +12,7 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end`. It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }`, keeping the log balanced and the turn-enclosure invariant intact. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)). -Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id)` snapshots the in-memory log, waits until that snapshot is durable, and returns it only when balanced; an open live turn rejects rather than receiving synthetic interruption boundaries. HMR also adopts a live prefix without closing its active turn. +Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id)` snapshots the in-memory log, waits until that snapshot is durable, and returns it only when balanced; an open live turn rejects rather than receiving synthetic interruption boundaries. A coordinator-backed cold load reserves the id across backend reads and repair writes, so concurrent publication of a same-id live session rejects and rolls back. HMR also adopts a live prefix without closing its active turn. ## `SessionLocation` — optional per-session artifact target diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 686efcaf40..4110edcd84 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -462,7 +462,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject. Implementations\n * MUST NOT crash-repair an identity still bound to a live Session: a balanced\n * live log may return as a durable snapshot, while an open live turn rejects.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', + jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject. Implementations\n * MUST NOT crash-repair an identity still bound to a live Session: a balanced\n * live log may return as a durable snapshot, while an open live turn rejects.\n * A coordinator-backed cold load reserves the identity across storage awaits,\n * so concurrent publication of a same-id live Session rejects.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', }, { signature: 'abstract list(): Promise', diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 5e5929a738..4f6b11b215 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -27,7 +27,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l Each `session/event` copies its event into the session controller and starts an eager drain without blocking the producer. Concurrent notifications share the current drain; events admitted during a write remain pending and trigger the next batch. `session/flush` is an observation barrier that waits until the controller has no current or pending batch. An eager failure is logged and retains the batch; the next explicit flush or backend teardown retries it and surfaces failure to its caller. -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 only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. A cold id follows storage repair normally. HMR adoption likewise reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. +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 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. 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/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index e43fe82125..e01bca9427 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -154,6 +154,8 @@ export class PersistenceCoordinator { private states = new Map() /** Lifecycle and write-behind state keyed by the exact live Session. */ private live = new Map() + /** Cold loads currently reserving an id across backend reads and repair writes. */ + private coldLoads = new Set() /** * Per-session serialization: every operation chains onto the prior one for the * same id, so writes for one session never interleave. Keyed by session id. @@ -251,7 +253,12 @@ export class PersistenceCoordinator { const selected = await this.serialize(id, async () => { const live = this.ctx.sessions.get(id) if (live !== undefined) return { live } - return { loaded: await this.loadCore(id) } + this.coldLoads.add(id) + try { + return { loaded: await this.loadCore(id) } + } finally { + this.coldLoads.delete(id) + } }) return 'loaded' in selected ? selected.loaded : this.loadLiveSnapshot(selected.live) } @@ -372,7 +379,12 @@ export class PersistenceCoordinator { }, `${this.backend.name} write path`) // Capture the header on creation and persist a fork's seed once. - ctx.on('session/created', (session) => { void this.initFor(session) }) + ctx.on('session/created', (session) => { + if (this.coldLoads.has(session.id)) { + throw new Error(`cannot publish session "${session.id}" while its persisted history is loading`) + } + void this.initFor(session) + }) // Keep a persistence-owned copy of each frozen event and start an eager drain. ctx.on('session/event', (session, event) => { @@ -394,6 +406,7 @@ export class PersistenceCoordinator { /** Start and observe one disposed session's final drain. */ private retire(session: Session): void { + if (!this.live.has(session)) return void this.retireCore(session).catch((error: unknown) => { this.ctx.logger.warn(`${this.backend.name}: session "${session.id}" retirement failed: ${String(error)}`) }) diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 8490b133bd..47e2b1c36d 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -79,6 +79,8 @@ export abstract class SessionPersistence extends Service { * versions and corruption in the committed prefix reject. Implementations * MUST NOT crash-repair an identity still bound to a live Session: a balanced * live log may return as a durable snapshot, while an open live turn rejects. + * A coordinator-backed cold load reserves the identity across storage awaits, + * so concurrent publication of a same-id live Session rejects. * @param id - the persisted session to reload. * @returns the header and a log ending on a balanced `turn/end`. */ diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 86b8580ff1..485d09dbb3 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -293,6 +293,48 @@ describe('PersistenceCoordinator stored identity', () => { await ctx.fiber.dispose() } }) + + it('reserves a cold id across asynchronous storage repair', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('cold-load-reservation') + const header = meta(id) + const start: SessionEvent = { + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + } + backend.store.set(id, { meta: header, events: [start] }) + const loadGate = Promise.withResolvers() + backend.beforeLoadStored = async () => { await loadGate.promise } + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const loading = coordinator.load(id) + await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) + + await expect(ctx.plugin(Object.assign((inner: Context) => { + inner.sessions.create(id, { seed: [start], meta: header }) + }, { inject: ['sessions'] }))).rejects.toThrow(/persisted history is loading/) + expect(ctx.sessions.get(id)).toBeUndefined() + + loadGate.resolve(true) + const loaded = await loading + expect(loaded.events.map(event => event.type)).toEqual(['turn/start', 'turn/end']) + + const resumed = ctx.sessions.create(id, { seed: loaded.events, meta: loaded.meta }) + await expect(ctx.sessions.flush(resumed)).resolves.toBeUndefined() + } finally { + loadGate.resolve(true) + await fiber.dispose() + await ctx.fiber.dispose() + } + }) }) describe('PersistenceCoordinator retirement', () => { @@ -399,17 +441,20 @@ describe('PersistenceCoordinator retirement', () => { appendGate.resolve(true) await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 1) }) - let reuse!: Session - await ctx.plugin(Object.assign((inner: Context) => { - reuse = inner.sessions.create(id) - }, { inject: ['sessions'] })) - const reuseFlush = ctx.sessions.flush(reuse) + await expect(ctx.plugin(Object.assign((inner: Context) => { + inner.sessions.create(id) + }, { inject: ['sessions'] }))).rejects.toThrow(/persisted history is loading/) loadGate.resolve(true) await expect(coldLoad).resolves.toMatchObject({ events: [{ seq: 0 }, { seq: 1 }], }) - await expect(reuseFlush).rejects.toThrow(/id collision/) + + let reuse!: Session + await ctx.plugin(Object.assign((inner: Context) => { + reuse = inner.sessions.create(id) + }, { inject: ['sessions'] })) + await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/id collision/) await vi.waitFor(() => { expect(backend.store.get(id)?.events.map(event => event.seq)).toEqual([0, 1]) }) From c596dddfe162b7441659e1c11db602616de08aeb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 23 Jul 2026 21:28:52 +0800 Subject: [PATCH 7/7] fix: return stored metadata from live loads --- ...collapse-persistence-flush-state.i18n.yaml | 4 +-- ...-07-23-collapse-persistence-flush-state.md | 3 ++- ...-23-collapse-persistence-flush-state.zh.md | 3 ++- docs/cordis-catalog/services.md | 3 ++- docs/core-data-structures/persistence.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../session-persistence/README.md | 4 +-- .../session-persistence/src/coordinator.ts | 5 +++- .../session-persistence/src/index.ts | 3 ++- .../tests/coordinator-contract.ts | 25 +++++++++++++------ 10 files changed, 36 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml index 9dd943ea72..e3e24181d8 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.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 -2026-07-23-collapse-persistence-flush-state.md: 21e99b0fc37f97e441f6d92eb02636767cabf51e -2026-07-23-collapse-persistence-flush-state.zh.md: 0b11c45ed7d5291daed4726b9dd7fb8878d4a783 +2026-07-23-collapse-persistence-flush-state.md: a9b0f6847712f47d46adb6b01c57563033738964 +2026-07-23-collapse-persistence-flush-state.zh.md: acb9f798d86b4ec41d975d9de23f36080d3d7848 diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md index 21e99b0fc3..a9b0f68477 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.md @@ -16,7 +16,7 @@ Each live `Session` has one controller containing `pending`, `init`, and the opt Initialization now enters the existing per-id operation chain once and calls the unserialized core operations while it owns that turn. The chain remains separate from the live controller because detached public `create`/`append`/`load` calls can race without a `Session` object and still require identity-level serialization. -Crash repair is cold-only. For a live identity, `load(id)` snapshots the authoritative in-memory header and events before awaiting their flush; it returns that durable snapshot when balanced and rejects an open turn without reading or repairing storage. A cold load reserves its identity synchronously inside the per-id chain before awaiting stored-prefix reads or repair writes; the `session/created` publication boundary rejects and rolls back a same-id live session until the reservation clears. HMR adoption remains separate through `loadStored` plus the coordinator's cwd check and truncates torn storage without closing the authoritative live turn. +Crash repair is cold-only. For a live identity, `load(id)` snapshots the authoritative in-memory events before awaiting their flush, then returns them with `SessionState.meta`, the header actually used for durable writes; it rejects an open turn without reading or repairing storage. A cold load reserves its identity synchronously inside the per-id chain before awaiting stored-prefix reads or repair writes; the `session/created` publication boundary rejects and rolls back a same-id live session until the reservation clears. HMR adoption remains separate through `loadStored` plus the coordinator's cwd check and truncates torn storage without closing the authoritative live turn. The live-controller map is also the retirement registry. Successful retirement drains and removes its controller; failed retirement leaves it in the map. Backend teardown stops event admission, flushes every controller still present, awaits remaining per-id operations, and closes the backend. No separate retirement set is needed to rediscover unfinished work. @@ -38,6 +38,7 @@ The live-controller map is also the retirement registry. Successful retirement d - The shared backend contract persists an open live turn, proves `load` rejects without writing synthetic closers, completes and retires the owner, then reloads the exact completed turn. - An AgentLoop regression races `resume()` against a live open turn and proves the original agent can still durably complete it without an injected `interrupted` boundary. - A controlled backend blocks `loadStored`, attempts same-id session publication while repair owns the reservation, and proves rollback leaves no ghost controller before a balanced resume succeeds. +- The ownerless-claim contract gives the live `Session` a different `createdAt`, then proves live and later cold loads both return the original stored header. ## Consequences diff --git a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md index 0b11c45ed7..acb9f798d8 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-23-collapse-persistence-flush-state.zh.md @@ -16,7 +16,7 @@ Status: implemented 初始化只进入现有的按 id 操作链一次,并在占有该轮执行权时调用未串行化的核心操作。该操作链与活跃控制器保持分离,因为公共 `create`、`append`、`load` 调用即使没有 `Session` 对象仍可能发生竞态,依然需要按标识串行执行。 -崩溃修复仅适用于冷态标识。对于活跃标识,`load(id)` 会在等待刷新完成前,先对内存中的权威标头和事件生成快照;若快照闭合,则返回这个已持久化的快照;若轮次仍打开,则在不读取或修复存储的情况下拒绝该次加载。冷态加载会先在按 id 操作链内同步占用对应标识,再等待读取已存储前缀或执行修复写入;在这项占用解除前,`session/created` 发布边界会拒绝同 id 活跃会话的发布并将其回滚。HMR 接管仍由 `loadStored` 与协调器的 cwd 检查独立处理,会截断撕裂的存储,但不会闭合权威的活跃轮次。 +崩溃修复仅适用于冷态标识。对于活跃标识,`load(id)` 会在等待刷新完成前,先对内存中的权威事件生成快照,再将这些事件与 `SessionState.meta`(即持久化写入实际使用的标头)一同返回;若轮次仍打开,则在不读取或修复存储的情况下拒绝该次加载。冷态加载会先在按 id 操作链内同步占用对应标识,再等待读取已存储前缀或执行修复写入;在这项占用解除前,`session/created` 发布边界会拒绝同 id 活跃会话的发布并将其回滚。HMR 接管仍由 `loadStored` 与协调器的 cwd 检查独立处理,会截断撕裂的存储,但不会闭合权威的活跃轮次。 活跃控制器映射同时也是退役注册表。退役成功时,系统排空并移除其控制器;退役失败时,控制器保留在映射中。后端资源销毁会停止接纳事件,刷新所有仍存在的控制器,等待其余按 id 操作完成,然后关闭后端。无需另设退役集合来重新发现未完成的工作。 @@ -38,6 +38,7 @@ Status: implemented - 共享后端契约会持久化一个仍打开的活跃轮次,证明 `load` 会拒绝且不会写入合成闭合事件,随后完成该轮次并让其所有者退役,最后重新加载完全相同的已完成轮次。 - AgentLoop 回归测试让 `resume()` 与一个仍打开的活跃轮次发生竞态,并证明原有的 agent(智能体)仍能完成该轮次并将其持久化,其间不会注入 `interrupted` 边界。 - 一个受控后端会阻塞 `loadStored`,在修复操作持有标识占用期间尝试发布同 id 会话,并证明回滚不会留下残留控制器,之后可以成功恢复一个闭合会话。 +- 无所有者声明契约会为活跃 `Session` 设置不同的 `createdAt`,并证明活跃加载和之后的冷态加载均返回最初存储的标头。 ## 后果 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0cd92b806a..0fc8bdb89c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -919,7 +919,8 @@ abstract append(id: SessionId, events: readonly SessionEvent[]): Promise * step and turn boundaries; only a torn final record is discarded. Unknown * versions and corruption in the committed prefix reject. Implementations * MUST NOT crash-repair an identity still bound to a live Session: a balanced - * live log may return as a durable snapshot, while an open live turn rejects. + * live log may return with its stored header as a durable snapshot, while an + * open live turn rejects. * A coordinator-backed cold load reserves the identity across storage awaits, * so concurrent publication of a same-id live Session rejects. * @param id - the persisted session to reload. diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 0c2ed557de..780036239a 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -12,7 +12,7 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end`. It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }`, keeping the log balanced and the turn-enclosure invariant intact. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)). -Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id)` snapshots the in-memory log, waits until that snapshot is durable, and returns it only when balanced; an open live turn rejects rather than receiving synthetic interruption boundaries. A coordinator-backed cold load reserves the id across backend reads and repair writes, so concurrent publication of a same-id live session rejects and rolls back. HMR also adopts a live prefix without closing its active turn. +Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id)` snapshots the in-memory log, waits until that snapshot is durable, and returns it with the stored header only when balanced; an open live turn rejects rather than receiving synthetic interruption boundaries. A coordinator-backed cold load reserves the id across backend reads and repair writes, so concurrent publication of a same-id live session rejects and rolls back. HMR also adopts a live prefix without closing its active turn. ## `SessionLocation` — optional per-session artifact target diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 4110edcd84..4f23118d6d 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -462,7 +462,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject. Implementations\n * MUST NOT crash-repair an identity still bound to a live Session: a balanced\n * live log may return as a durable snapshot, while an open live turn rejects.\n * A coordinator-backed cold load reserves the identity across storage awaits,\n * so concurrent publication of a same-id live Session rejects.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', + jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject. Implementations\n * MUST NOT crash-repair an identity still bound to a live Session: a balanced\n * live log may return with its stored header as a durable snapshot, while an\n * open live turn rejects.\n * A coordinator-backed cold load reserves the identity across storage awaits,\n * so concurrent publication of a same-id live Session rejects.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', }, { signature: 'abstract list(): Promise', diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 4f6b11b215..b2dca790ad 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -11,7 +11,7 @@ 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 flushed balanced snapshot for a live session, rejecting while its turn is open; 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 the stored header plus a flushed balanced event snapshot for a live session, rejecting while its turn is open; 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. | | `list(): Promise` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | ## Invariants every backend must honor @@ -27,7 +27,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l Each `session/event` copies its event into the session controller and starts an eager drain without blocking the producer. Concurrent notifications share the current drain; events admitted during a write remain pending and trigger the next batch. `session/flush` is an observation barrier that waits until the controller has no current or pending batch. An eager failure is logged and retains the batch; the next explicit flush or backend teardown retries it and surfaces failure to its caller. -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 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. +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. 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/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index e01bca9427..c66e7f434b 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -286,9 +286,12 @@ export class PersistenceCoordinator { /** Return a durable balanced live snapshot without applying cold crash repair. */ private async loadLiveSnapshot(session: Session): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - const meta = structuredClone(session.header) const events = session.events.map(event => structuredClone(event)) await this.flush(session) + const state = this.states.get(session.id) + /* v8 ignore next -- successful flush always publishes this live session's durable state */ + if (state === undefined) throw new Error(`session "${session.id}" lost persistence state during load`) + const meta = structuredClone(state.meta) if (events.length === 0) throw new Error(`session "${session.id}" not found`) if (interruptedTurnClosers(events).length > 0) { throw new Error(`cannot load session "${session.id}" while its live turn is open; use the live Session or wait for the turn to close`) diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 47e2b1c36d..eb91c004f6 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -78,7 +78,8 @@ export abstract class SessionPersistence extends Service { * step and turn boundaries; only a torn final record is discarded. Unknown * versions and corruption in the committed prefix reject. Implementations * MUST NOT crash-repair an identity still bound to a live Session: a balanced - * live log may return as a durable snapshot, while an open live turn rejects. + * live log may return with its stored header as a durable snapshot, while an + * open live turn rejects. * A coordinator-backed cold load reserves the identity across storage awaits, * so concurrent publication of a same-id live Session rejects. * @param id - the persisted session to reload. diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 05b86c699d..4c5450ef19 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -612,20 +612,31 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< const { ctx, fiber } = await freshCtx(fix) try { // Materialize and load (ownerless, cursor = 6). - await ctx.sessionPersistence.create(meta('claim', WORK)) + const storedMeta = meta('claim', WORK) + await ctx.sessionPersistence.create(storedMeta) await ctx.sessionPersistence.append(SessionId('claim'), oneTurnLog()) - const { events } = await ctx.sessionPersistence.load(SessionId('claim')) + const { events, meta: durableMeta } = await ctx.sessionPersistence.load(SessionId('claim')) // A live session SEEDED with the loaded log PLUS a new turn claims the // ownerless state and persists only the suffix. - const cont = ctx.sessions.create(SessionId('claim'), { seed: [ - ...events, - { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, - ], meta: { cwd: WORK } }) + let cont!: Session + const contFiber = await ctx.plugin(Object.assign((inner: Context) => { + cont = inner.sessions.create(SessionId('claim'), { seed: [ + ...events, + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } }, + ], meta: { cwd: WORK, createdAt: 2000 } }) + }, { inject: ['sessions'] })) await ctx.sessions.flush(cont) const loaded = await ctx.sessionPersistence.load(SessionId('claim')) expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) + expect(loaded.meta).toEqual(durableMeta) + expect(loaded.meta.createdAt).toBe(1000) + + await contFiber.dispose() + await vi.waitFor(async () => { + expect((await ctx.sessionPersistence.load(SessionId('claim'))).meta).toEqual(durableMeta) + }) } finally { await fiber.dispose() await fix.cleanup()