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()