mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(session-persistence): invalidate stale preparations
This commit is contained in:
@@ -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-sqlite/README.md
|
||||
README.md: de70eb80559611f0409e05d3b2a6f69777a781a6
|
||||
README.zh.md: 53df3ef835ebc01b4a1ef7109190c4b64863a5ba
|
||||
README.md: d01ba6ebfa1f59a9e4d58f3032bbe1d016970290
|
||||
README.zh.md: c11bef5467a3401948b58d5fd8e3301b72ff3d03
|
||||
|
||||
@@ -22,7 +22,7 @@ On filesystems with POSIX modes, the backend requests mode `0700` for missing di
|
||||
- **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row).
|
||||
- **Interrupted-turn close on load.** `load()` implements the shared [crash-recovery contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md): preserve the valid interrupted turn, append its synthetic closing events in one transaction, and remove only a torn tail row. Committed parse errors or sequence gaps make the session unloadable. Because recovery mutates stored rows, the next append starts from a balanced log and accurate cursor.
|
||||
- **Non-mutating inspection.** `inspect()` returns an immutable balanced logical view and may synthesize recovery closers in memory, without deleting a torn tail row, appending recovery rows, or changing the lightweight revision.
|
||||
- **Lightweight revisions.** `listSnapshots(signal?)` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. It checks cancellation before and after shared readiness and the synchronous metadata query; the query itself is non-preemptible.
|
||||
- **Lightweight revisions.** `listSnapshots(signal?)` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. A full-prefix read captures that revision and its event rows in one read transaction, while `readStoredRevision()` queries only the session row to validate retained preparations. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. It checks cancellation before and after shared readiness and the synchronous metadata query; the query itself is non-preemptible.
|
||||
|
||||
## Configuration (schemastery)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ SQLite 持久会话存储后端:第二个 `SessionPersistence` 实现(见[
|
||||
- **延迟实体化。**`create()` 只在内存记录意图,第一次 `append` 前不写行。从未 append 的会话没有 `sessions` 行,因此不在 `list()` 中(它精确报告有行的会话)。
|
||||
- **在 load 时关闭中断轮次。**`load()` 实现共享[崩溃恢复契约](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md):保留有效中断轮次,在一个事务中追加合成关闭事件,并只移除撕裂尾部行。已提交解析错误或序列缺口使会话无法加载。恢复会变更已存储行,因此下一次 append 从平衡日志和准确游标开始。
|
||||
- **非变更检查。**`inspect()` 返回不可变、平衡的逻辑视图,并可在内存中合成恢复 closer,但不会删除撕裂尾部行、追加恢复行或更改轻量修订。
|
||||
- **轻量修订。**`listSnapshots(signal?)` 组合不可变存储与数据库文件身份、每实体化 incarnation id,以及在每个变更事务中递增的每会话计数器。它在不解析事件行的情况下保持未变观察稳定,并区分独立存储和重建的同 id 日志。它在共享就绪和同步元数据查询前后检查取消;查询本身不可抢占。
|
||||
- **轻量修订。**`listSnapshots(signal?)` 组合不可变存储与数据库文件身份、每实体化 incarnation id,以及在每个变更事务中递增的每会话计数器。完整前缀读取在同一个读事务中捕获该 revision 及其事件行,`readStoredRevision()` 则只查询 session 行来校验保留的 preparation。它在不解析事件行的情况下保持未变观察稳定,并区分独立存储和重建的同 id 日志。它在共享就绪和同步元数据查询前后检查取消;查询本身不可抢占。
|
||||
|
||||
## 配置(schemastery)
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ import { dirname, resolve } from 'node:path'
|
||||
import {
|
||||
DEFAULT_PREPARED_SESSION_CACHE_SIZE, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
|
||||
type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
|
||||
type SessionInspection, type StoredPrefix, type StoredSuffix,
|
||||
type SessionInspection, type SessionPersistenceRevision as PersistenceRevision,
|
||||
type StoredPrefix, type StoredSuffix,
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
@@ -38,6 +39,13 @@ function surfaceBindings(event: SessionEvent): [string | null, string | null] {
|
||||
]
|
||||
}
|
||||
|
||||
/** Build the source-qualified revision shared by full and lightweight reads. */
|
||||
function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevision {
|
||||
return SessionPersistenceRevision(
|
||||
`${storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclusively create a missing database file with owner-only permissions.
|
||||
* Existing files retain their modes, and errors other than `EEXIST` propagate.
|
||||
@@ -187,6 +195,15 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
return this.readPrefix(id, signal)
|
||||
}
|
||||
|
||||
/** Read one row's revision without loading its events. */
|
||||
async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise<PersistenceRevision | undefined> {
|
||||
signal?.throwIfAborted()
|
||||
await this.ready
|
||||
signal?.throwIfAborted()
|
||||
const row = this.rowFor(id)
|
||||
return row === undefined ? undefined : sqliteRevision(this.storeIdentity, row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek-capable suffix read: SQL selects `seq >= fromSeq` directly, so the
|
||||
* read scales with the suffix, not the log. Torn rows past the preserved
|
||||
@@ -216,15 +233,33 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
signal?.throwIfAborted()
|
||||
await this.ready
|
||||
signal?.throwIfAborted()
|
||||
const row = this.rowFor(id)
|
||||
if (row === undefined) return undefined
|
||||
const meta = rowToMeta(row)
|
||||
const eventRows = this.db
|
||||
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq')
|
||||
.all(id) as unknown as EventRow[]
|
||||
this.db.exec('BEGIN')
|
||||
let snapshot: { row: SessionRow; eventRows: EventRow[] } | undefined
|
||||
try {
|
||||
const row = this.rowFor(id)
|
||||
if (row !== undefined) {
|
||||
const eventRows = this.db
|
||||
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq')
|
||||
.all(id) as unknown as EventRow[]
|
||||
snapshot = { row, eventRows }
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore start -- synchronous read failures only need transaction cleanup before propagation. */
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
if (snapshot === undefined) return undefined
|
||||
const { row, eventRows } = snapshot
|
||||
const { preserved, tornFrom } = scanRows(eventRows)
|
||||
return { meta, events: preserved, ...tornFrom !== undefined ? { tornMarker: tornFrom } : {} }
|
||||
return {
|
||||
meta: rowToMeta(row),
|
||||
events: preserved,
|
||||
revision: sqliteRevision(this.storeIdentity, row),
|
||||
...tornFrom !== undefined ? { tornMarker: tornFrom } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -576,6 +576,19 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
await b.dispose()
|
||||
})
|
||||
|
||||
it('binds a full stored prefix to the same revision as a lightweight read', async () => {
|
||||
const b = await backend()
|
||||
const m = meta('stored-prefix-revision')
|
||||
await b.ctx.sessionPersistence.create(m)
|
||||
await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||
const persistence = b.ctx.sessionPersistence as SessionPersistenceSqlite
|
||||
|
||||
const stored = await persistence.loadStored(m.id)
|
||||
expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id))
|
||||
expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined()
|
||||
await b.dispose()
|
||||
})
|
||||
|
||||
it('changes revisions when a deleted session id is materialized again in the same database', async () => {
|
||||
const path = await freshDbPath()
|
||||
const m = meta('recreated-revision')
|
||||
|
||||
Reference in New Issue
Block a user