mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(tui): add safe session resume flow
This commit is contained in:
@@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i
|
||||
|
||||
## Storage model
|
||||
|
||||
Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row).
|
||||
Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id, and `live_session_leases` stores one PID and exec-stable nonce per live session. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row).
|
||||
|
||||
The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations.
|
||||
|
||||
@@ -33,7 +33,7 @@ interface Config {
|
||||
|
||||
## Write path
|
||||
|
||||
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.
|
||||
Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session and starts an eager drain. A live lease is acquired in a `BEGIN IMMEDIATE` transaction before flush or resume and released after the exact lifecycle retires. 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
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@ import { mkdir, open } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import {
|
||||
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
|
||||
type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
|
||||
type StoredPrefix,
|
||||
sessionLeaseProcessIsLive, shareSessionLiveLease,
|
||||
type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner,
|
||||
type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix,
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
@@ -161,6 +162,14 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
return this.coordinator.inspect(id)
|
||||
}
|
||||
|
||||
override claimLive(id: SessionId): Promise<SessionLiveLease> {
|
||||
return this.coordinator.claimLive(id)
|
||||
}
|
||||
|
||||
override isLive(id: SessionId): Promise<boolean> {
|
||||
return this.coordinator.isLive(id)
|
||||
}
|
||||
|
||||
// One method serves both public `list` and the backend hook; delegating it to
|
||||
// the coordinator would call this hook recursively.
|
||||
|
||||
@@ -271,6 +280,55 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
}))
|
||||
}
|
||||
|
||||
/** Atomically acquire one SQLite-backed process lease. */
|
||||
async acquireLive(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise<void>> {
|
||||
await this.ready
|
||||
return shareSessionLiveLease(
|
||||
`sqlite:${this.storeIdentity}:${id}`,
|
||||
() => Promise.resolve().then(() => this.acquireLiveRow(id, owner)),
|
||||
)
|
||||
}
|
||||
|
||||
private acquireLiveRow(id: SessionId, owner: SessionLiveOwner): () => Promise<void> {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const current = this.liveLeaseFor(id)
|
||||
if (current !== undefined
|
||||
&& (current.pid !== owner.pid || current.nonce !== owner.nonce)) {
|
||||
if (sessionLeaseProcessIsLive(current.pid)) {
|
||||
throw new Error(`session "${id}" is occupied by another live process`)
|
||||
}
|
||||
this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ?').run(id)
|
||||
}
|
||||
this.db.prepare(`
|
||||
INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET pid = excluded.pid, nonce = excluded.nonce
|
||||
`).run(id, owner.pid, owner.nonce)
|
||||
this.db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
await this.ready
|
||||
this.db.prepare(
|
||||
'DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?',
|
||||
).run(id, owner.pid, owner.nonce)
|
||||
}
|
||||
}
|
||||
|
||||
/** Report a non-stale SQLite lease and remove a crashed owner's row. */
|
||||
async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise<boolean> {
|
||||
await this.ready
|
||||
const current = this.liveLeaseFor(id)
|
||||
if (current === undefined) return false
|
||||
if ((current.pid === owner.pid && current.nonce === owner.nonce)
|
||||
|| sessionLeaseProcessIsLive(current.pid)) return true
|
||||
this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?')
|
||||
.run(id, current.pid, current.nonce)
|
||||
return false
|
||||
}
|
||||
|
||||
/** Close the database handle (awaited by the coordinator's dispose, post-drain). */
|
||||
async close(): Promise<void> {
|
||||
await this.ready
|
||||
@@ -284,6 +342,11 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
return this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as unknown as SessionRow | undefined
|
||||
}
|
||||
|
||||
private liveLeaseFor(id: SessionId): { pid: number; nonce: string } | undefined {
|
||||
return this.db.prepare('SELECT pid, nonce FROM live_session_leases WHERE session_id = ?')
|
||||
.get(id) as { pid: number; nonce: string } | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert-or-replace a session's metadata row. The only caller is the first
|
||||
* materializing `appendBatch`, so writing the row IS the materialization (its
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
|
||||
* layout; orthogonal to a session's own `version` (which versions the EVENT
|
||||
* vocabulary, stored per session in the `sessions` row).
|
||||
*/
|
||||
export const SCHEMA_VERSION = 8
|
||||
export const SCHEMA_VERSION = 9
|
||||
|
||||
/**
|
||||
* A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
|
||||
@@ -68,7 +68,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
* rather than being migrated in place.
|
||||
* @param path - the SQLite database file to open (created when absent).
|
||||
* @param journalMode - validated journal pragma.
|
||||
* @returns the open handle with pragmas applied and all three tables ensured.
|
||||
* @returns the open handle with pragmas applied and all tables ensured.
|
||||
*/
|
||||
export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync {
|
||||
const db = new DatabaseSync(path)
|
||||
@@ -128,6 +128,13 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
|
||||
PRIMARY KEY (session_id, seq)
|
||||
) STRICT
|
||||
`)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS live_session_leases (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
pid INTEGER NOT NULL,
|
||||
nonce TEXT NOT NULL
|
||||
) STRICT
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import { dirname, join } from 'node:path'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
|
||||
import { sessionLiveOwner } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts'
|
||||
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
|
||||
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
|
||||
@@ -442,7 +443,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
})
|
||||
|
||||
it('exposes the schema version constant', () => {
|
||||
expect(SCHEMA_VERSION).toBe(8)
|
||||
expect(SCHEMA_VERSION).toBe(9)
|
||||
})
|
||||
|
||||
it('keeps the revision stable for an empty repair hook', async () => {
|
||||
@@ -458,6 +459,38 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
})
|
||||
|
||||
describe('SessionPersistenceSqlite: edge cases', () => {
|
||||
it('claims, rejects, reclaims, inspects, and releases SQLite live leases', async () => {
|
||||
const path = await freshDbPath()
|
||||
const b = await backend(path)
|
||||
await b.ctx.sessionPersistence.list()
|
||||
const concrete = b.ctx.sessionPersistence as SessionPersistenceSqlite
|
||||
const owner = sessionLiveOwner()
|
||||
const db = openDatabase(path, 'wal')
|
||||
const insert = db.prepare('INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)')
|
||||
insert.run('occupied-lease', process.pid, 'another-owner')
|
||||
insert.run('stale-claim', 2_147_483_647, 'dead-owner')
|
||||
insert.run('stale-inspect', 2_147_483_647, 'dead-owner')
|
||||
insert.run('owned-inspect', owner.pid, owner.nonce)
|
||||
db.close()
|
||||
|
||||
await expect(concrete.acquireLive(SessionId('occupied-lease'), owner))
|
||||
.rejects.toThrow('occupied by another live process')
|
||||
const claim = await concrete.acquireLive(SessionId('stale-claim'), owner)
|
||||
expect(await concrete.inspectLive(SessionId('owned-inspect'), owner)).toBe(true)
|
||||
expect(await concrete.inspectLive(SessionId('stale-inspect'), owner)).toBe(false)
|
||||
expect(await concrete.inspectLive(SessionId('missing-inspect'), owner)).toBe(false)
|
||||
await claim()
|
||||
await b.dispose()
|
||||
|
||||
const memory = new Context()
|
||||
await memory.plugin(SessionStore)
|
||||
await memory.plugin(SessionPersistenceSqlite, { path: ':memory:' })
|
||||
const memoryClaim = await memory.sessionPersistence.claimLive(SessionId('memory-live'))
|
||||
expect(await memory.sessionPersistence.isLive(SessionId('memory-live'))).toBe(true)
|
||||
await memoryClaim.release()
|
||||
await memory.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects and closes a current-schema database with an invalid store identity', async () => {
|
||||
const path = await freshDbPath()
|
||||
const db = openDatabase(path, 'wal')
|
||||
|
||||
Reference in New Issue
Block a user