fix(session-persistence): address preparation review feedback

This commit is contained in:
imccyu
2026-08-06 02:53:48 +08:00
parent 466390c1af
commit feb2c35cef
35 changed files with 364 additions and 124 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md
2026-06-18-shared-persistence-write-coordinator.md: b7a9f7306b7d24e2c924a623b02f7329634f242f
2026-06-18-shared-persistence-write-coordinator.zh.md: 9ceacb516bb29561dfdaa8222e9232c1bce10c8d
2026-06-18-shared-persistence-write-coordinator.md: 66b73b60ceec9497f1f1226747b8cebd831eb426
2026-06-18-shared-persistence-write-coordinator.zh.md: 424ce6ec7384e8af7b979a29f58c31379a1d1850

View File

@@ -35,7 +35,7 @@ The single design choice that keeps the seam clean: the crash-repair "where is t
## Testing
The shared `runPersistenceContract` (public-API contract) runs for every backend and proves that `inspect` balances an interrupted logical view without changing storage or revisions before `prepare` or `load` commits recovery. `runCoordinatorContract` (`tests/coordinator-contract.ts`) covers preparation reuse and reservation plus adoption, HMR, collision, session and backend disposal drains, and crash-tail repair through an in-memory reference, JSONL, and SQLite. Coordinator-specific tests cover bounded prepared-state eviction, 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 because the contract crash case produces synthetic closers without a torn marker.
The shared `runPersistenceContract` (public-API contract) runs for every backend and proves that `inspect` balances an interrupted logical view without changing storage or revisions before `prepare` or `load` commits recovery. `runCoordinatorContract` (`tests/coordinator-contract.ts`) covers adoption, HMR, collision, session and backend disposal drains, and crash-tail repair through an in-memory reference, JSONL, and SQLite. `persistence.spec.ts` and `preparations.spec.ts` cover preparation reuse and reservation, bounded prepared-state eviction, 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 because the contract crash case produces synthetic closers without a torn marker.
## Alternatives considered

View File

@@ -35,7 +35,7 @@ Status: implemented
## 测试
共享的 `runPersistenceContract`(公开 API 契约)为每个后端运行,并证明 `inspect` 会配平被中断的逻辑视图但不改变存储或修订版本,随后由 `prepare``load` 提交恢复。`runCoordinatorContract``tests/coordinator-contract.ts`通过内存参考实现、JSONL 与 SQLite 覆盖准备复用与预留,以及接管、HMR、碰撞、会话与后端 dispose 排空和崩溃尾部修复。协调器专属测试覆盖有界准备状态淘汰、立即执行的后续批次、存活控制器清理、同 id 链尾竞态、排空失败重试与关闭顺序。各后端自身的测试规格只保留存储机制。每个真实后端都有一个经由协调器的崩溃尾部修复测试,以覆盖不透明 marker 分支,因为契约中的崩溃用例会产生合成 closers却不会产生 torn marker。
共享的 `runPersistenceContract`(公开 API 契约)为每个后端运行,并证明 `inspect` 会配平被中断的逻辑视图但不改变存储或修订版本,随后由 `prepare``load` 提交恢复。`runCoordinatorContract``tests/coordinator-contract.ts`通过内存参考实现、JSONL 与 SQLite 覆盖接管、HMR、碰撞、会话与后端 dispose 排空和崩溃尾部修复。`persistence.spec.ts``preparations.spec.ts` 覆盖准备复用与预留、有界准备状态淘汰、立即执行的后续批次、存活控制器清理、同 id 链尾竞态、排空失败重试与关闭顺序。各后端自身的测试规格只保留存储机制。每个真实后端都有一个经由协调器的崩溃尾部修复测试,以覆盖不透明 marker 分支,因为契约中的崩溃用例会产生合成 closers却不会产生 torn marker。
## 曾考虑的替代方案

View File

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

View File

@@ -44,7 +44,7 @@ Cold continuable-subagent access follows the same path. Descriptor authorization
## Verification
The shared persistence contract and coordinator tests pin same-id in-flight sharing, exact Session reuse across inspect and prepare, single repair commit, exclusive reservation, release after failed setup, ready-entry LRU eviction, append rejection during reservation, and publication of only the reserved Session. Agent-loop and continuable-subagent tests pin the common publication pipeline and inspection-to-resume path across cancellation and teardown.
The shared persistence contract pins non-mutating balanced cold inspection and later repair. `persistence.spec.ts` and `preparations.spec.ts` pin same-id in-flight sharing, exact Session reuse across inspect and prepare, single repair commit, exclusive reservation, release after failed setup, ready-entry LRU eviction, append rejection during reservation, and publication of only the reserved Session. Agent-loop and continuable-subagent tests pin the common publication pipeline and inspection-to-resume path across cancellation and teardown.
## Alternatives considered

View File

@@ -44,7 +44,7 @@ agent loop智能体循环通过同一条设置与发布流水线消费这
## 验证
共享持久化契约和协调器测试覆盖同 id 进行中读取共享、检查与准备之间的精确 Session 复用、修复只提交一次、独占预留、设置失败后释放、就绪项 LRU 淘汰、预留期间拒绝 append以及只允许发布预留 Session。agent loop 与 continuable subagent 测试覆盖统一发布流水线,以及取消和拆卸期间从检查到恢复的路径。
共享持久化契约覆盖无变更且已配平的冷检查与后续修复。`persistence.spec.ts``preparations.spec.ts` 覆盖同 id 进行中读取共享、检查与准备之间的精确 Session 复用、修复只提交一次、独占预留、设置失败后释放、就绪项 LRU 淘汰、预留期间拒绝 append以及只允许发布预留 Session。agent loop 与 continuable subagent 测试覆盖统一发布流水线,以及取消和拆卸期间从检查到恢复的路径。
## 考虑过的替代方案

View File

@@ -1199,14 +1199,17 @@ async prepare(id: SessionId, signal?: AbortSignal): Promise<SessionPreparation>
abstract load(id: SessionId): Promise<SessionInspection>
/**
* Inspect an immutable balanced logical session without committing recovery
* or publishing it. A complete interrupted turn receives synthetic closers
* in memory and a torn physical tail remains untouched. Coordinator-backed
* implementations retain the exact unpublished Session for bounded reuse by
* a later {@link prepare}; callers borrow only its immutable header and log.
* Inspect an immutable logical session without committing recovery or
* publishing it. A cold complete interrupted turn receives synthetic closers
* in memory and a torn physical tail remains untouched. An already-live
* Session instead yields its current immutable snapshot, which may contain an
* open turn and its `session/end-seed` boundary. Coordinator-backed
* implementations retain the exact cold unpublished Session for bounded
* reuse by a later {@link prepare}; callers borrow only its immutable header
* and log.
* @param id - the persisted session to inspect.
* @param signal - optional cancellation for queued and backend read work.
* @returns the validated header and balanced logical event log.
* @returns the validated header and current logical event log.
*/
abstract inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection>
@@ -1252,7 +1255,7 @@ abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot
Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionInspection](../core-data-structures/persistence.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) · [SessionPreparation](../core-data-structures/persistence.md)
Source: [`packages/session-persistence/session-persistence/src/index.ts:66`](../../packages/session-persistence/session-persistence/src/index.ts)
Source: [`packages/session-persistence/session-persistence/src/index.ts:70`](../../packages/session-persistence/session-persistence/src/index.ts)
## `ctx.sessionProjectionCache` — `SessionProjectionCache`
@@ -1589,7 +1592,11 @@ Persistence is intentionally not implemented here — persistence plugins subscr
* `dsh-agent-loop`'s creation transaction).
*
* @param id - the session id; omitted, the store mints `session-<n>`.
* @param options - seed events and/or creation metadata for the header.
* @param options - seed events and/or creation metadata for the header. With
* `seedSource: 'persistence'`, metadata and events must be fresh detached
* graphs whose ownership transfers to this call: they are validated and
* frozen in place through {@link Session.fromRestore}, so the caller must
* retain no mutable aliases.
* @returns the live session, already entered and announced.
* @throws if a session with `id` already exists, metadata is not a plain
* lossless-JSON record with valid scalar fields, or `meta.cwd` is a

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/persistence.md
persistence.md: 4b4b1ed0c0809bb277cd0118bb14f893faa4d821
persistence.zh.md: 226dbd03c514d2c76838d7ee64ecc699a7852cba
persistence.md: 0968496201defa869d94925e8e5ae3c5da1bbd37
persistence.zh.md: efb01427b4355e531fb9b86922223cf27d3b3db0

View File

@@ -16,7 +16,7 @@ A backend that reloads a log crashed mid-turn finds an open `turn/start` with no
Repair applies only to cold sessions. For a live id, `SessionPersistence.load(id)` waits until the authoritative in-memory snapshot is durable and returns it only when balanced; an open live turn rejects rather than receiving synthetic interruption boundaries. HMR adopts a live prefix without closing its active turn.
`SessionPersistence.inspect(id)` constructs an immutable balanced logical Session without publishing it or writing recovery. Torn physical tails remain untouched, and synthetic interruption closers exist only in memory. Coordinator-backed implementations retain that exact unpublished Session in a bounded LRU, so repeated history reads and a later `prepare(id)` share one read, decompression, validation, freeze, and Session construction. `prepare(id)` reserves the Session, commits pending repair, and returns a disposable publication handle; `load(id)` uses the same machinery to commit repair without publication. The [Session preparation decision](../../.agents/notes/implemented/architecture/2026-08-05-session-preparation.md) owns this lifecycle.
`SessionPersistence.inspect(id)` constructs an immutable logical Session without publishing it or writing recovery. Cold inspection balances an interrupted turn in memory while leaving torn physical tails untouched; inspection of an already-live Session borrows its current immutable snapshot and may therefore contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU, so repeated history reads and a later `prepare(id)` share one read, decompression, validation, freeze, and Session construction. `prepare(id)` reserves the Session, commits pending repair, and returns a disposable publication handle; `load(id)` uses the same machinery to commit repair without publication. The [Session preparation decision](../../.agents/notes/implemented/architecture/2026-08-05-session-preparation.md) owns this lifecycle.
## `SessionLocation` — optional per-session artifact target

View File

@@ -16,7 +16,7 @@
修复仅适用于冷会话。对于活跃 id`SessionPersistence.load(id)` 会等待权威内存快照完成持久化并且只在日志平衡时返回若活跃轮次仍未闭合则拒绝操作而不是添加合成的中断边界。HMR 会接管活跃前缀,而不会关闭其中正在进行的轮次。
`SessionPersistence.inspect(id)` 会构造一个不可变且已配平的逻辑 Session但不发布它也不写入恢复内容。撕裂的物理尾部保持不变,合成的中断 closer 只存在于内存中。使用协调器的实现会在有界 LRU 中保留这个精确的未发布 Session因此重复历史读取与后续 `prepare(id)` 可复用同一次读取、解压、验证、冻结及 Session 构造。`prepare(id)` 会预留该 Session、提交待处理修复并返回可 dispose 的发布句柄;`load(id)` 使用相同机制提交修复,但不会发布 Session。该生命周期由 [Session 准备阶段决策](../../.agents/notes/implemented/architecture/2026-08-05-session-preparation.md)定义。
`SessionPersistence.inspect(id)` 会构造一个不可变的逻辑 Session但不发布它也不写入恢复内容。冷检查会在内存中配平中断的 turn同时保持撕裂的物理尾部不变检查已经实时存在的 Session 则借用其当前不可变快照,因此可能包含打开的 turn。使用协调器的实现会在有界 LRU 中保留这个精确的未发布 Session因此重复历史读取与后续 `prepare(id)` 可复用同一次读取、解压、验证、冻结及 Session 构造。`prepare(id)` 会预留该 Session、提交待处理修复并返回可 dispose 的发布句柄;`load(id)` 使用相同机制提交修复,但不会发布 Session。该生命周期由 [Session 准备阶段决策](../../.agents/notes/implemented/architecture/2026-08-05-session-preparation.md)定义。
## `SessionLocation`——可选的逐会话产物目标

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/session-query.md
session-query.md: d92af4bac34f7d41457e9e193111c3a53fe8022e
session-query.zh.md: 8070dfda61a2945fca554939f65ae0f8b85078db
session-query.md: e7514dd6c3bc20a07395663bff40ce65e1363b78
session-query.zh.md: 4c3dd4d435dbd8a20fbd4db5da1a7d649c2e6d0b

View File

@@ -338,6 +338,7 @@ The closed code union distinguishes request validation, missing targets, malform
/** Stable machine-routable failure taxonomy for session reads, traces, and search. */
type SessionQueryErrorCode =
| 'SESSION_QUERY_ABORTED'
| 'SESSION_QUERY_CORRUPT_SESSION'
| 'SESSION_QUERY_EVENT_NOT_FOUND'
| 'SESSION_QUERY_INDEX_FAILED'
| 'SESSION_QUERY_INVALID_CONFIG'

View File

@@ -338,6 +338,7 @@ interface SessionEventTraceObservation extends SessionEventTrace {
/** Stable machine-routable failure taxonomy for session reads, traces, and search. */
type SessionQueryErrorCode =
| 'SESSION_QUERY_ABORTED'
| 'SESSION_QUERY_CORRUPT_SESSION'
| 'SESSION_QUERY_EVENT_NOT_FOUND'
| 'SESSION_QUERY_INDEX_FAILED'
| 'SESSION_QUERY_INVALID_CONFIG'

View File

@@ -590,7 +590,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'abstract inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection>',
jsDoc: '/**\n * Inspect an immutable balanced logical session without committing recovery\n * or publishing it. A complete interrupted turn receives synthetic closers\n * in memory and a torn physical tail remains untouched. Coordinator-backed\n * implementations retain the exact unpublished Session for bounded reuse by\n * a later {@link prepare}; callers borrow only its immutable header and log.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the validated header and balanced logical event log.\n */',
jsDoc: '/**\n * Inspect an immutable logical session without committing recovery or\n * publishing it. A cold complete interrupted turn receives synthetic closers\n * in memory and a torn physical tail remains untouched. An already-live\n * Session instead yields its current immutable snapshot, which may contain an\n * open turn and its `session/end-seed` boundary. Coordinator-backed\n * implementations retain the exact cold unpublished Session for bounded\n * reuse by a later {@link prepare}; callers borrow only its immutable header\n * and log.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the validated header and current logical event log.\n */',
},
{
signature: 'abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>',
@@ -740,7 +740,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
{
signature: 'create(id?: SessionId, options?: CreateSessionOptions): Session',
jsDoc: '/**\n * Create a session owned by the calling fiber: disposing that fiber stops\n * event notification and removes the session from the store. `options.seed`\n * populates the session with a copy of those events (replay/fork);\n * `options.meta` attaches creation metadata (validated absolute `cwd`, seed\n * and parent lineage, and delegation depth) as the immutable\n * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).\n *\n * For an agent whose session must be torn down IN ORDER with its loop (so the\n * loop\'s final events are published before the store attachment ends), do NOT use this\n * — fold the session lifecycle into the agent\'s own effect via\n * {@link prepare} + {@link enter} + {@link announce} (see\n * `dsh-agent-loop`\'s creation transaction).\n *\n * @param id - the session id; omitted, the store mints `session-<n>`.\n * @param options - seed events and/or creation metadata for the header.\n * @returns the live session, already entered and announced.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path (storage backends key directories off it).\n */',
jsDoc: '/**\n * Create a session owned by the calling fiber: disposing that fiber stops\n * event notification and removes the session from the store. `options.seed`\n * populates the session with a copy of those events (replay/fork);\n * `options.meta` attaches creation metadata (validated absolute `cwd`, seed\n * and parent lineage, and delegation depth) as the immutable\n * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).\n *\n * For an agent whose session must be torn down IN ORDER with its loop (so the\n * loop\'s final events are published before the store attachment ends), do NOT use this\n * — fold the session lifecycle into the agent\'s own effect via\n * {@link prepare} + {@link enter} + {@link announce} (see\n * `dsh-agent-loop`\'s creation transaction).\n *\n * @param id - the session id; omitted, the store mints `session-<n>`.\n * @param options - seed events and/or creation metadata for the header. With\n * `seedSource: \'persistence\'`, metadata and events must be fresh detached\n * graphs whose ownership transfers to this call: they are validated and\n * frozen in place through {@link Session.fromRestore}, so the caller must\n * retain no mutable aliases.\n * @returns the live session, already entered and announced.\n * @throws if a session with `id` already exists, metadata is not a plain\n * lossless-JSON record with valid scalar fields, or `meta.cwd` is a\n * non-absolute path (storage backends key directories off it).\n */',
},
{
signature: 'prepare(id?: SessionId, options?: PrepareSessionOptions): Session',

View File

@@ -215,7 +215,7 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
|| !Object.hasOwn(event, 'seq') || typeof event['seq'] !== 'number'
|| !Number.isSafeInteger(event['seq']) || event['seq'] < 0
|| !Object.hasOwn(event, 'time') || typeof event['time'] !== 'number'
|| !Number.isSafeInteger(event['time']) || event['time'] < 0
|| !Number.isSafeInteger(event['time'])
|| !Object.hasOwn(event, 'data')) {
throw new Error(`seed event at index ${index} has an invalid event envelope`)
}
@@ -821,7 +821,11 @@ export class SessionStore extends Service {
* `dsh-agent-loop`'s creation transaction).
*
* @param id - the session id; omitted, the store mints `session-<n>`.
* @param options - seed events and/or creation metadata for the header.
* @param options - seed events and/or creation metadata for the header. With
* `seedSource: 'persistence'`, metadata and events must be fresh detached
* graphs whose ownership transfers to this call: they are validated and
* frozen in place through {@link Session.fromRestore}, so the caller must
* retain no mutable aliases.
* @returns the live session, already entered and announced.
* @throws if a session with `id` already exists, metadata is not a plain
* lossless-JSON record with valid scalar fields, or `meta.cwd` is a

View File

@@ -15,6 +15,11 @@ import { MessageId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import {
PersistenceCoordinator,
type PersistenceBackend,
type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
@@ -114,6 +119,62 @@ describe('attached updatedAt excludes end-seed', () => {
})
})
describe('cold history recovery view', () => {
it('shows in-memory interruption repair without activating the session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
const sessionId = sid('session-interrupted')
const meta = header(sessionId, 1000)
const stored: StoredPrefix<never> = {
meta,
events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }],
}
const backend: PersistenceBackend<never> = {
name: 'history-recovery-test',
loadStored: id => Promise.resolve(id === sessionId ? structuredClone(stored) : undefined),
appendBatch: () => Promise.resolve(),
commitRepair: () => Promise.resolve(),
list: () => Promise.resolve([structuredClone(meta)]),
}
const coordinator = new PersistenceCoordinator(ctx, backend)
ctx.provide('sessionPersistence', {
list: (signal?: AbortSignal) => backend.list(signal),
inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
locate: () => undefined,
} as never)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 }))
if (!history.result.ok) throw new Error('history failed')
expect(history.result.value.events.map(entry => entry.event)).toMatchInlineSnapshot(`
[
{
"data": {
"turn": 1,
},
"seq": 0,
"time": 1,
"type": "turn/start",
},
{
"data": {
"reason": {
"kind": "interrupted",
},
"turn": 1,
},
"seq": 1,
"time": 1,
"type": "turn/end",
},
]
`)
expect(ctx.sessions.get(sessionId)).toBeUndefined()
await ctx.fiber.dispose()
})
})
describe('subagent ownership fence', () => {
it('reads a cold child without an Agent and rejects generic resume or adoption', async () => {
const ctx = new Context()

View File

@@ -105,11 +105,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
// Resolve once so later process.cwd() changes cannot split one backend across roots.
this.root = resolve(config.root)
// Programmatic wrappers may construct the backend without Schemastery normalization.
const preparedSessionCacheSize = config.preparedSessionCacheSize
?? DEFAULT_PREPARED_SESSION_CACHE_SIZE
this.packChunks = config.packChunks ?? DEFAULT_PACK_CHUNKS
this.compression = config.compression ?? DEFAULT_COMPRESSION
this.assertUsableRoot()
this.coordinator = new PersistenceCoordinator<JsonlTornMarker>(this.ctx, this, {
preparedSessionCacheSize: config.preparedSessionCacheSize ?? DEFAULT_PREPARED_SESSION_CACHE_SIZE,
preparedSessionCacheSize,
})
}

View File

@@ -105,11 +105,14 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
constructor(ctx: Context, public config: Config) {
super(ctx)
// Programmatic wrappers may construct the backend without Schemastery normalization.
const preparedSessionCacheSize = config.preparedSessionCacheSize
?? DEFAULT_PREPARED_SESSION_CACHE_SIZE
// Open asynchronously so directory creation does not block plugin apply;
// every storage hook awaits the same readiness promise.
this.ready = this.openDb(config.path, (config as Required<Config>).journalMode)
this.coordinator = new PersistenceCoordinator<number>(this.ctx, this, {
preparedSessionCacheSize: (config as Required<Config>).preparedSessionCacheSize,
preparedSessionCacheSize,
})
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-persistence/session-persistence/README.md
README.md: 4c00c28184c0046d5507270980822529bfe3bc08
README.zh.md: 32461cec5efd279a86bf3af40b23e7e54d33a89e
README.md: 675f59cebe338c7bbf9bed4436db26b4593ee473
README.zh.md: ff6589c0f29e1a634b27a4118878dc7b503c9e98

View File

@@ -15,7 +15,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
| `append(id, events): Promise<void>` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
| `prepare(id, signal?): Promise<SessionPreparation>` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. |
| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after supported same-version shape upgrades and commit cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed shapes, and unknown `version` reject. |
| `inspect(id, signal?): Promise<{ meta; events }>` | Return the same upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. Synthetic recovery closers exist only in memory, the physical torn tail remains untouched, and coordinator-backed implementations retain the exact unpublished Session in a bounded LRU for later `prepare`. Same-id inspections share an in-flight read. |
| `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`. Same-id inspections share an in-flight read. |
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The detached physical-suffix primitive: return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless a supported old shape requires prefix context for normalization; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that fold only the tail past a watermark. |
| `list(signal?): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. |
| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. |

View File

@@ -15,7 +15,7 @@
| `append(id, events): Promise<void>` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq非 JSON 可序列化数据会被拒绝,并命名违规类型。 |
| `prepare(id, signal?): Promise<SessionPreparation>` | 预留恢复使用的精确未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复并在 dispose 时将未发布 reservation 释放回有界缓存。 |
| `load(id): Promise<{ meta; events }>` | 在升级受支持的同版本形状后返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的形状和未知 `version` 会被拒绝。 |
| `inspect(id, signal?): Promise<{ meta; events }>` | 返回同一份已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。合成恢复 closer 只存在于内存,物理撕裂尾部保持不变基于协调器的实现会在有界 LRU 中保留精确的未发布 Session供后续 `prepare` 使用。同 id 检查共享进行中的读取。 |
| `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer物理撕裂尾部保持不变;已经实时存在的视图则是当前不可变快照,可能包含打开的 turn。基于协调器的实现会在有界 LRU 中保留精确的未发布 Session供后续 `prepare` 使用。同 id 检查共享进行中的读取。 |
| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 脱离的物理后缀原语:返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端SQLite只读后缀除非受支持的旧形状需要前缀上下文才能完成规范化顺序后端JSONL解析整个产物并向前跳过。用于只续折水位之后尾部的 checkpoint 消费方。 |
| `list(signal?): Promise<SessionHeader[]>` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 |
| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值不加载事件日志。日志及其后端存储不变时修订保持相等append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 |

View File

@@ -22,6 +22,18 @@ import type { SessionPreparationReservation } from './preparations.ts'
/** Default number of detached session preparations retained by a coordinator. */
export const DEFAULT_PREPARED_SESSION_CACHE_SIZE = 5
/** Durable session contents failed validation after a successful backend read. */
export class SessionPersistenceCorruptionError extends Error {
/**
* @param message - stable corruption context.
* @param options - original validation failure.
*/
constructor(message: string, options: ErrorOptions) {
super(message, options)
this.name = 'SessionPersistenceCorruptionError'
}
}
/** Coordinator policy supplied by a concrete persistence backend. */
export interface PersistenceCoordinatorOptions {
/** Maximum completed unpublished preparations retained for reuse. */
@@ -623,7 +635,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
}
const reservation = await this.preparations.reserve(
id,
() => this.serialize(id, () => this.prepareCore(id, signal), signal),
() => this.serialize(id, () => this.prepareCore(id)),
source => this.serialize(id, () => this.commitPrepared(source), signal),
signal,
)
@@ -674,16 +686,17 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* Inspect a logical session without publishing it or committing recovery.
* @param id - persisted session to inspect.
* @param signal - optional cancellation for preparation work.
* @returns immutable prepared metadata and balanced events.
* @returns immutable prepared metadata and events; a live view may have an open turn.
*/
async inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection> {
await this.waitForRetirement(id, signal)
signal?.throwIfAborted()
if (this.retirements.has(id)) await this.waitForRetirement(id, signal)
const live = this.ctx.sessions.get(id)
if (live !== undefined) return this.inspectLive(live)
try {
const source = await this.preparations.inspect(
id,
() => this.serialize(id, () => this.prepareCore(id, signal), signal),
() => this.serialize(id, () => this.prepareCore(id)),
signal,
)
const attached = this.ctx.sessions.get(id)
@@ -776,29 +789,36 @@ export class PersistenceCoordinator<TornMarker = unknown> {
}
signal?.throwIfAborted()
if (stored === undefined) throw new Error(`session "${id}" not found`)
const { meta, events, tornMarker } = stored
this.assertStoredId(id, meta)
this.assertVersion(meta)
const storedEvents = adoptStoredEvents(events, id)
try {
const { meta, events, tornMarker } = stored
this.assertStoredId(id, meta)
this.assertVersion(meta)
const storedEvents = adoptStoredEvents(events, id)
// Preserve complete interrupted events and synthesize only missing closers.
const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent)
const balanced = [...storedEvents, ...closers]
const session = this.ctx.sessions.prepare(id, {
seed: balanced,
meta,
seedSource: 'persistence',
})
const inspection: SessionInspection = Object.freeze({
meta: session.header,
events: Object.freeze(balanced),
})
return {
inspection,
session,
sessionLength: session.events.length,
tornMarker,
closers,
// Preserve complete interrupted events and synthesize only missing closers.
const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent)
const balanced = [...storedEvents, ...closers]
const session = this.ctx.sessions.prepare(id, {
seed: balanced,
meta,
seedSource: 'persistence',
})
const inspection: SessionInspection = Object.freeze({
meta: session.header,
events: Object.freeze(balanced),
})
return {
inspection,
session,
sessionLength: session.events.length,
tornMarker,
closers,
}
} catch (error: unknown) {
throw new SessionPersistenceCorruptionError(
`stored session "${id}" failed validation: ${String(error)}`,
{ cause: error },
)
}
}

View File

@@ -31,7 +31,11 @@ export interface SessionInspection {
}
// The backend-agnostic write-path orchestration first-party backends compose.
export { DEFAULT_PREPARED_SESSION_CACHE_SIZE, PersistenceCoordinator } from './coordinator.ts'
export {
DEFAULT_PREPARED_SESSION_CACHE_SIZE,
PersistenceCoordinator,
SessionPersistenceCorruptionError,
} from './coordinator.ts'
export type {
PersistenceBackend,
PersistenceCoordinatorOptions,
@@ -134,14 +138,17 @@ export abstract class SessionPersistence extends Service {
abstract load(id: SessionId): Promise<SessionInspection>
/**
* Inspect an immutable balanced logical session without committing recovery
* or publishing it. A complete interrupted turn receives synthetic closers
* in memory and a torn physical tail remains untouched. Coordinator-backed
* implementations retain the exact unpublished Session for bounded reuse by
* a later {@link prepare}; callers borrow only its immutable header and log.
* Inspect an immutable logical session without committing recovery or
* publishing it. A cold complete interrupted turn receives synthetic closers
* in memory and a torn physical tail remains untouched. An already-live
* Session instead yields its current immutable snapshot, which may contain an
* open turn and its `session/end-seed` boundary. Coordinator-backed
* implementations retain the exact cold unpublished Session for bounded
* reuse by a later {@link prepare}; callers borrow only its immutable header
* and log.
* @param id - the persisted session to inspect.
* @param signal - optional cancellation for queued and backend read work.
* @returns the validated header and balanced logical event log.
* @returns the validated header and current logical event log.
*/
abstract inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection>

View File

@@ -55,8 +55,8 @@ export class SessionPreparations<Source extends PreparedSource, CommitState> {
load: () => Promise<Source>,
signal?: AbortSignal,
): Promise<Source> {
const { entry, created } = this.entryFor(id, load)
const loaded = signal === undefined || created
const entry = this.entryFor(id, load)
const loaded = signal === undefined
? await entry.result
: await observeQueuedAbort(entry.result, signal)
const source = entry.source ?? loaded
@@ -78,10 +78,8 @@ export class SessionPreparations<Source extends PreparedSource, CommitState> {
commit: (source: Source) => Promise<{ source: Source; state: CommitState }>,
signal?: AbortSignal,
): Promise<SessionPreparationReservation<Source, CommitState> | undefined> {
const { entry, created } = this.entryFor(id, load)
await (signal === undefined || created
? entry.result
: observeQueuedAbort(entry.result, signal))
const entry = this.entryFor(id, load)
await (signal === undefined ? entry.result : observeQueuedAbort(entry.result, signal))
while (this.entries.get(id) === entry && entry.phase !== 'ready') {
const settled = entry.reservationSettled
/* v8 ignore next -- committing/reserved transitions install this waiter synchronously. */
@@ -132,7 +130,7 @@ export class SessionPreparations<Source extends PreparedSource, CommitState> {
&& entry.reservation !== undefined) {
return entry.reservation
}
throw new Error(`cannot publish session "${session.id}" while a persisted preparation exists`)
throw new Error(`cannot publish session "${session.id}": persisted state already owns this identity`)
}
/**
@@ -216,20 +214,37 @@ export class SessionPreparations<Source extends PreparedSource, CommitState> {
private entryFor(
id: SessionId,
load: () => Promise<Source>,
): { entry: PreparationEntry<Source, CommitState>; created: boolean } {
): PreparationEntry<Source, CommitState> {
const existing = this.entries.get(id)
if (existing !== undefined) return { entry: existing, created: false }
const result = Promise.resolve().then(load)
const entry: PreparationEntry<Source, CommitState> = { id, result, phase: 'loading' }
if (existing !== undefined) return existing
const deferred = Promise.withResolvers<Source>()
const entry: PreparationEntry<Source, CommitState> = {
id,
result: deferred.promise,
phase: 'loading',
}
this.entries.set(id, entry)
void result.then((source) => {
if (this.entries.get(id) !== entry) return
entry.source = source
entry.phase = 'ready'
}, () => {
let loading: Promise<Source>
try {
// Start immediately so a same-tick serialized append queues behind this
// read. The deferred result settles only after the entry becomes ready.
loading = load()
} catch (error: unknown) {
this.remove(entry)
deferred.reject(error)
return entry
}
void loading.then((source) => {
if (this.entries.get(id) === entry) {
entry.source = source
entry.phase = 'ready'
}
deferred.resolve(source)
}, (error: unknown) => {
this.remove(entry)
deferred.reject(error)
})
return { entry, created: true }
return entry
}
private makeReady(entry: PreparationEntry<Source, CommitState>): void {

View File

@@ -353,7 +353,7 @@ describe('PersistenceCoordinator stored identity', () => {
await expect(ctx.plugin(Object.assign((inner: Context) => {
inner.sessions.create(id, { seed: [start], meta: header })
}, { inject: ['sessions'] }))).rejects.toThrow(/persisted preparation exists/)
}, { inject: ['sessions'] }))).rejects.toThrow(/persisted state already owns this identity/)
expect(ctx.sessions.get(id)).toBeUndefined()
loadGate.resolve(true)
@@ -590,6 +590,59 @@ describe('PersistenceCoordinator session preparations', () => {
}
})
it('queues a same-tick cold append behind preparation readiness', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
const id = SessionId('inspect-cold-append-race')
backend.store.set(id, { meta: meta(id), events: oneTurnLog() })
let coordinator!: PersistenceCoordinator<never>
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
try {
const inspection = coordinator.inspect(id)
const append = coordinator.append(id, [{
type: 'turn/start',
seq: oneTurnLog().length,
time: 7,
data: { turn: 2 },
}])
await expect(inspection).resolves.toMatchObject({ meta: { id } })
await expect(append).resolves.toBeUndefined()
expect(backend.loadAttempts).toBe(1)
expect(backend.store.get(id)?.events).toHaveLength(oneTurnLog().length + 1)
} finally {
await fiber.dispose()
await ctx.fiber.dispose()
}
})
it('inspects an open live turn without balancing it', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
let coordinator!: PersistenceCoordinator<never>
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
try {
const session = ctx.sessions.create(SessionId('inspect-live-open-turn'))
session.append('turn/start', { turn: 1 })
const inspected = await coordinator.inspect(session.id)
expect(inspected.events).toBe(session.events)
expect(inspected.events.map(event => event.type)).toEqual(['turn/start'])
await expect(coordinator.load(session.id)).rejects.toThrow(/live turn is open/)
} finally {
await fiber.dispose()
await ctx.fiber.dispose()
}
})
it('keeps synthetic recovery in memory during inspect and commits it only once on prepare', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -770,54 +823,36 @@ describe('PersistenceCoordinator observation cancellation', () => {
}
})
it('waits for active cooperative inspection cleanup before rejecting cancellation', async () => {
it('keeps a shared cold read alive when its creating inspect is cancelled', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
const id = SessionId('active-inspect-cancellation')
const id = SessionId('creating-inspect-cancellation')
backend.store.set(id, { meta: meta(id), events: oneTurnLog() })
const cleanupGate = Promise.withResolvers<boolean>()
let cleanupComplete = false
backend.beforeLoadStored = async (_attempt, signal) => {
await new Promise<void>((resolve) => {
signal?.addEventListener('abort', () => {
void cleanupGate.promise.then(() => {
cleanupComplete = true
resolve()
})
}, { once: true })
})
throw new Error('backend cancellation after cleanup')
}
const loadGate = Promise.withResolvers<boolean>()
backend.beforeLoadStored = () => loadGate.promise.then(() => undefined)
let coordinator!: PersistenceCoordinator<never>
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
let prepared: Awaited<ReturnType<typeof coordinator.prepare>> | undefined
try {
const controller = new AbortController()
const reason = new Error('active inspect cancelled')
const pending = coordinator.inspect(id, controller.signal)
let observedReason: unknown
const observed = pending.catch((error: unknown) => {
observedReason = error
})
const reason = new Error('creating inspect cancelled')
const inspection = coordinator.inspect(id, controller.signal)
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) })
const reservation = coordinator.prepare(id)
controller.abort(reason)
await Promise.resolve()
expect(observedReason).toBeUndefined()
expect(cleanupComplete).toBe(false)
cleanupGate.resolve(true)
await observed
expect(cleanupComplete).toBe(true)
expect(observedReason).toBe(reason)
const backendFailure = new Error('later inspection failure')
backend.beforeLoadStored = () => Promise.reject(backendFailure)
await expect(coordinator.inspect(id)).rejects.toBe(backendFailure)
await expect(inspection).rejects.toBe(reason)
loadGate.resolve(true)
prepared = await reservation
expect(prepared.session.id).toBe(id)
expect(backend.loadAttempts).toBe(1)
} finally {
cleanupGate.resolve(true)
loadGate.resolve(true)
prepared?.[Symbol.dispose]()
await fiber.dispose()
await ctx.fiber.dispose()
}
@@ -1092,7 +1127,7 @@ describe('PersistenceCoordinator retirement', () => {
await expect(ctx.plugin(Object.assign((inner: Context) => {
inner.sessions.create(id)
}, { inject: ['sessions'] }))).rejects.toThrow(/persisted preparation exists/)
}, { inject: ['sessions'] }))).rejects.toThrow(/persisted state already owns this identity/)
loadGate.resolve(true)
await expect(coldLoad).resolves.toMatchObject({

View File

@@ -39,6 +39,25 @@ describe('SessionPreparations inspection', () => {
expect(preparations.has(id)).toBe(false)
})
it('keeps a shared load alive when its first observer cancels', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const id = SessionId('cancelled-first-observer')
const gate = Promise.withResolvers<PreparedSource>()
const load = vi.fn(() => gate.promise)
const controller = new AbortController()
const reason = new Error('first observer cancelled')
const first = preparations.inspect(id, load, controller.signal)
const joined = preparations.inspect(id, load)
controller.abort(reason)
await expect(first).rejects.toBe(reason)
const source = prepared(id)
gate.resolve(source)
await expect(joined).resolves.toBe(source)
await expect(preparations.inspect(id, load)).resolves.toBe(source)
expect(load).toHaveBeenCalledOnce()
})
it('removes failed and invalidated in-flight loads without changing their observers', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const failedId = SessionId('failed-inspection')
@@ -63,6 +82,15 @@ describe('SessionPreparations inspection', () => {
await expect(rejected).rejects.toBe(failure)
})
it('removes a load that throws before returning its promise', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const id = SessionId('synchronous-load-failure')
const failure = new Error('synchronous load failure')
await expect(preparations.inspect(id, () => { throw failure })).rejects.toBe(failure)
expect(preparations.has(id)).toBe(false)
})
it('evicts ready entries while leaving reserved entries alone', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const reservedA = await preparations.reserve(

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-query/session-query/README.md
README.md: df97333be3b2c2cf71dd8c9287959bcbd83a5063
README.zh.md: 649d06cdc7c3a61f9f2459466bc9fdc3a42554e6
README.md: b75c1f23264cfa7c9323970b3c1a77ddcfe69be0
README.zh.md: e1b9727ff892047c56d006e02906f3270fb11293

View File

@@ -17,7 +17,7 @@ English | [中文](README.zh.md)
- `traceSession(sessionId, signal?)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`.
- `traceEvent(request, signal?)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive.
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles.
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable; a successfully read durable record that fails Session validation reports `SESSION_QUERY_CORRUPT_SESSION` instead. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles.
## Filtering and extraction

View File

@@ -17,7 +17,7 @@
- `traceSession(sessionId, signal?)` 只读取一次语料库,返回从直接父级向外的祖先,以及确定性的递归后代树。`complete: false` 标识第一个缺失父级;与目标相连的循环会以 `SESSION_QUERY_INVALID_LINEAGE` 失败。
- `traceEvent(request, signal?)` 只加载一次逻辑日志,返回其克隆源 header、直接位置替换和直接已记录来源信息。`replacementChain` 沿位置替换者跟踪到最终替换;来源链接仍不传递。
持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久化后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前先执行列表查询,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪的取消信号会传递给持久化列表查询;事件跟踪和事件读取的取消信号会传递给持久化列表查询和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。针对已知实时会话且预先中止的标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量标题观察执行一次元数据列表查询,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id并保留每个标题自己观察到的 header供下游授权使用。取消不会启动已排队检查且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。
持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败;已经成功读取、但无法通过 Session 校验的持久化记录则以 `SESSION_QUERY_CORRUPT_SESSION` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久化后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前先执行列表查询,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪的取消信号会传递给持久化列表查询;事件跟踪和事件读取的取消信号会传递给持久化列表查询和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。针对已知实时会话且预先中止的标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量标题观察执行一次元数据列表查询,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id并保留每个标题自己观察到的 header供下游授权使用。取消不会启动已排队检查且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。
## 过滤与提取

View File

@@ -19,6 +19,7 @@ export interface Config {
/** Stable machine-routable failure taxonomy for session reads, traces, and search. */
export type SessionQueryErrorCode =
| 'SESSION_QUERY_ABORTED'
| 'SESSION_QUERY_CORRUPT_SESSION'
| 'SESSION_QUERY_EVENT_NOT_FOUND'
| 'SESSION_QUERY_INDEX_FAILED'
| 'SESSION_QUERY_INVALID_CONFIG'

View File

@@ -2,7 +2,7 @@
import type { Context, Fiber } from 'cordis'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import SessionPersistence, { SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence'
import type { SessionRecord } from './types.ts'
import { SessionQueryError } from './config.ts'
import { assertSessionHeadersCompatible } from './sources.ts'
@@ -274,6 +274,13 @@ async function inspectPersisted(
return await persistence.inspect(sessionId, signal)
} catch (error: unknown) {
if (signal?.aborted) signal.throwIfAborted()
if (error instanceof SessionPersistenceCorruptionError) {
throw new SessionQueryError(
`stored session "${sessionId}" is corrupt: ${errorMessage(error)}`,
'SESSION_QUERY_CORRUPT_SESSION',
{ cause: error },
)
}
throw new SessionQueryError(
`failed to inspect session "${sessionId}": ${errorMessage(error)}`,
'SESSION_QUERY_PERSISTENCE_FAILED',

View File

@@ -23,6 +23,10 @@ const SAFE_SESSION_QUERY_FAILURES = {
code: 'SESSION_QUERY_ABORTED',
message: 'session query was cancelled',
},
SESSION_QUERY_CORRUPT_SESSION: {
code: 'SESSION_QUERY_CORRUPT_SESSION',
message: 'session event history is corrupt',
},
SESSION_QUERY_EVENT_NOT_FOUND: {
code: 'SESSION_QUERY_EVENT_NOT_FOUND',
message: 'session event was not found',

View File

@@ -141,6 +141,26 @@ describe('tool-session-query with the real SQLite provider', () => {
}),
surfaceOp: 'append',
},
{
type: 'user/message',
seq: 2,
time: -124,
data: createUserMessage({
content: [{ type: 'text', text: 'pre-epoch fractional needle' }],
source: { kind: 'user' },
}),
surfaceOp: 'append',
},
{
type: 'user/message',
seq: 3,
time: -123,
data: createUserMessage({
content: [{ type: 'text', text: 'pre-epoch fractional needle' }],
source: { kind: 'user' },
}),
surfaceOp: 'append',
},
])
const caller = ctx.sessions.create(SessionId('fractional-caller'), {
@@ -184,5 +204,27 @@ describe('tool-session-query with the real SQLite provider', () => {
expect(emptySameMillisecond.isError).toBe(false)
expect(emptySameMillisecond.content.map(block => block.type === 'text' ? block.text : '').join('\n'))
.toContain('No prior event matches found.')
const preEpochLower = await execute({
session_id: persisted,
query: 'pre-epoch fractional needle',
time_from: '1969-12-31T23:59:59.87600001Z',
})
expect(preEpochLower.isError).toBe(false)
const preEpochLowerText = preEpochLower.content
.map(block => block.type === 'text' ? block.text : '').join('\n')
expect(preEpochLowerText).toContain('seq 3')
expect(preEpochLowerText).not.toContain('seq 2')
const preEpochUpper = await execute({
session_id: persisted,
query: 'pre-epoch fractional needle',
time_to: '1969-12-31T19:59:59.8769999-04:00',
})
expect(preEpochUpper.isError).toBe(false)
const preEpochUpperText = preEpochUpper.content
.map(block => block.type === 'text' ? block.text : '').join('\n')
expect(preEpochUpperText).toContain('seq 2')
expect(preEpochUpperText).not.toContain('seq 3')
})
})

View File

@@ -218,6 +218,8 @@ function perChildDiagnosticReason(
): 'corrupt' | 'unavailable' | undefined {
if (!(error instanceof SessionQueryError)) return undefined
switch (error.code) {
case 'SESSION_QUERY_CORRUPT_SESSION':
return 'corrupt'
case 'SESSION_QUERY_SESSION_NOT_FOUND':
case 'SESSION_QUERY_EVENT_NOT_FOUND':
case 'SESSION_QUERY_PERSISTENCE_FAILED':

View File

@@ -268,7 +268,7 @@ describe('SubagentService.listChildren', () => {
})
})
it('maps a child rejected by persisted Session preparation to unavailable', async () => {
it('diagnoses a child rejected by persisted Session preparation as corrupt', async () => {
const { ctx, parent } = await setup([])
// The surface-eligible user/message lacks its required surfaceOp. The
// first-party persistence inspection rejects before session-query can fold it.
@@ -285,7 +285,7 @@ describe('SubagentService.listChildren', () => {
{ type: 'subagent/descriptor', seq: 2, time: 3, data: descriptorPayload('broken surface') },
] as SessionEvent[])
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'unavailable' }])
expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'corrupt' }])
})
it('diagnoses a malformed descriptor payload as corrupt', async () => {