Merge remote-tracking branch 'origin/master' into codex/pr48-repo-hardening-rfcs

# Conflicts:
#	docs/adr/README.md
#	docs/rfc/009-session-persistence-and-resumability.md
#	docs/rfc/README.md
#	docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md
#	docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md
#	examples/acp-agent/tests/acp.e2e.ts
#	packages/acp/README.md
#	packages/acp/src/index.ts
#	packages/acp/tests/stream-update.spec.ts
#	packages/agent-loop/src/loop.ts
#	packages/tools/src/index.ts
This commit is contained in:
Tianyi Cui
2026-06-18 23:41:14 +08:00
104 changed files with 2586 additions and 590 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-session-persistence-sqlite
A SQLite durable session-persistence backend — a second `SessionPersistence` implementation ([ADR 0018](../../docs/adr/0018-session-persistence.md)), built to validate that the abstract seam and the shared `runPersistenceContract` suite are genuinely backend-agnostic. It satisfies the SAME contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes.
A SQLite durable session-persistence backend — a second `SessionPersistence` implementation ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)), built to validate that the abstract seam and the shared `runPersistenceContract` suite are genuinely backend-agnostic. It satisfies the SAME contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes.
> **TODO:** this backend talks to `node:sqlite` directly. If a cordis database service (`cordis/db` / a `@cordisjs` SQL driver plugin) is adopted, route through that instead of holding a raw `DatabaseSync` here — the contract surface (`SessionPersistence`) would not change, only the storage driver.

View File

@@ -238,7 +238,7 @@ export class SessionPersistenceSqlite extends SessionPersistence {
// agree — both append routes then continue with no special-casing. Synthesize
// the boundary events (a step/end if a step was open, then a
// turn/end {kind:'interrupted'}); the interrupted turn's real events are
// preserved, never truncated (ADR 0018).
// preserved, never truncated (the session-persistence RFC).
const closers = interruptedTurnClosers(preserved)
const balanced = [...preserved, ...closers]
@@ -285,6 +285,35 @@ export class SessionPersistenceSqlite extends SessionPersistence {
return { meta, events: balanced }
}
private async adoptLiveStoredPrefix(session: Session, seed: readonly SessionEvent[]): Promise<void> {
await this.ready
const row = this.rowFor(session.header.id)
/* v8 ignore next -- caller checked row existence */
if (row === undefined) throw new Error(`session "${session.header.id}" not found`)
const meta = rowToMeta(row)
this.assertVersion(meta)
const rows = this.db
.prepare('SELECT seq, type, time, data FROM events WHERE session_id = ? ORDER BY seq')
.all(session.header.id) as unknown as EventRow[]
const { preserved, tornFrom } = scanRows(rows)
if (!seedCoversPrefix(seed, preserved)) {
throw new Error(`session "${session.header.id}" already has a persisted log that does not match this live session (id collision)`)
}
if (tornFrom !== undefined) {
this.db.prepare('DELETE FROM events WHERE session_id = ? AND seq >= ?').run(session.header.id, tornFrom)
}
this.states.set(session.header.id, {
meta: { ...meta },
cursor: preserved.length,
materialized: true,
owner: session,
})
const suffix = seed.slice(preserved.length)
if (suffix.length > 0) await this.appendCore(session.header.id, suffix)
}
async list(): Promise<SessionMeta[]> {
await this.ready
// Every metadata row is a materialized session: the row is written only by
@@ -489,16 +518,9 @@ export class SessionPersistenceSqlite extends SessionPersistence {
const row = this.rowFor(id)
if (row !== undefined) {
const stored = this.eventsFor(id)
if (!seedCoversPrefix(seed, stored)) {
throw new Error(`session "${id}" already has a persisted log that does not match this live session (id collision)`)
}
await this.serialize(id, () => this.loadCore(id))
const adopted = this.states.get(id)
/* v8 ignore next -- loadCore always sets the state for the id */
if (adopted !== undefined) adopted.owner = session
const suffix = seed.slice(stored.length)
if (suffix.length > 0) await this.append(id, suffix)
// Adopt a LIVE prefix without crash-repairing an open turn as interrupted;
// HMR may still append the real completion from the live Session.
await this.serialize(id, () => this.adoptLiveStoredPrefix(session, seed))
return
}

View File

@@ -131,7 +131,7 @@ export function rowToEvent(row: EventRow): SessionEvent {
* fully-written rows sit after the last `turn/end`. Those are PRESERVED — a
* single turn can be huge in a long-horizon task, so truncating it would
* destroy real work; the backend closes the orphaned open turn with a synthetic
* `turn/end {kind:'interrupted'}` on load (ADR 0018). The ONLY thing excluded is
* `turn/end {kind:'interrupted'}` on load (the session-persistence RFC). The ONLY thing excluded is
* a torn trailing fragment — a row whose `data` never parses, or a seq gap —
* AFTER the last committed `turn/end`; that bounds the preserved region and its
* seq is returned as `tornFrom` so `load` can physically delete it.

View File

@@ -108,6 +108,35 @@ describe('scanRows', () => {
})
})
describe('SessionPersistenceSqlite: HMR adoption', () => {
it('does not crash-repair an active open turn as interrupted', async () => {
const path = await freshDbPath()
const ctx = new Context()
await ctx.plugin(SessionStore)
const first = await ctx.plugin(SessionPersistenceSqlite, { path })
const session = ctx.sessions.create('hmr-open', { meta: { cwd: '/hmr' } })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
await ctx.parallel('session/flush', session)
await first.dispose()
const db = openDatabase(path)
db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
.run('hmr-open', 2, 'step/end', 2, '{"torn":')
db.close()
const second = await ctx.plugin(SessionPersistenceSqlite, { path })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open'))
expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } })
await second.dispose()
await ctx.fiber.dispose()
})
})
describe('SessionPersistenceSqlite: durability and crash semantics', () => {
it('an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load', async () => {
const path = await freshDbPath()