feat(session-persistence): abstract seam + JSONL backend + wiring

Add the durable session-persistence capability seam (ADR 0016): an
abstract SessionPersistence service (dsh-session-persistence,
ctx.sessionPersistence) defining create/append/load/list/has/delete/
update over the existing SessionEvent — no parallel persisted type — and
a first implementation (dsh-session-persistence-jsonl): an append-only
JSONL log per session with crash-safe atomic writes, truncation-repair
of a never-committed crash tail, and a read/replay path. SessionMeta
(format version, cwd, lineage) travels out-of-log via session.header.

A shared runPersistenceContract suite holds every backend to the same
append-only / contiguous-seq / lazy-materialization / serializability
semantics.

Config-driven create() now uses a per-run ${id}-session-<uuid> session
id so a fixed name no longer collides with an on-disk log once a durable
backend is loaded; each run is a new session (a demo simplification). The
examples drop their hand-rolled session-jsonl.ts and load the JSONL
backend via cordis.yml; CI smoke-loads it too.

The agent-facing create/resume factory that consumes load() is a
separate seam, deferred to a follow-up; this change stops at the load
primitive and does not reach into the loop.
This commit is contained in:
Tianyi Cui
2026-06-15 21:05:46 +08:00
parent b0bc0b5792
commit df4b7d3d9a
33 files changed
+2959 -89

No files matched your search

+1 -1
View File
@@ -29,7 +29,7 @@ Type a coding task. The agent's only tools are `bash` (+ `bash_output` / `bash_k
| `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin |
| `bash` (`dsh-bash-local`) + `tool-bash` | the executor seam + tool schemas as separate plugins |
| `agent-loop` | agent created from config with a coding system prompt |
| `src/session-jsonl.ts` | write-behind persistence on `session/event` + `session/flush` (copied from echo-agent) |
| `session-persistence` (`dsh-session-persistence-jsonl`) | durable JSONL persistence (`root: ./.sessions`): append-only event log per session, crash-safe atomic writes — the shared backend, no per-example file |
| `src/stdio-chat.ts` | UI as a plugin; copied from echo-agent with reasoning-dimming and an exit-on-idle close handler for piped stdin. Example-local on purpose — extract a shared UI package when a third example needs it |
## End-to-end tests (`yarn test:e2e`, key-gated)
+4 -2
View File
@@ -74,8 +74,10 @@
failures before moving on. Verify your work by running the code or
tests. Keep answers brief and factual.
- id: session-jsonl
name: './src/session-jsonl.ts'
- id: session-persistence
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: './.sessions'
- id: stdio-chat
name: './src/stdio-chat.ts'
@@ -1,36 +0,0 @@
import { appendFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from 'cordis'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
export const name = 'session-jsonl'
export const inject = ['sessions']
/**
* Minimal persistence plugin: buffers session events (write-behind) and
* drains to a JSONL file at every `session/flush` checkpoint — the pattern a
* real JSONL/sqlite persistence plugin would follow.
*/
export function apply(ctx: Context) {
const buffers = new Map<Session, SessionEvent[]>()
const path = (session: Session) => join(import.meta.dirname, '..', `${session.id}.jsonl`)
ctx.on('session/event', (session, event) => {
let buffer = buffers.get(session)
if (!buffer) buffers.set(session, buffer = [])
buffer.push(event)
})
const flush = async (session: Session) => {
const buffer = buffers.get(session)
if (!buffer?.length) return
const lines = buffer.splice(0).map(event => JSON.stringify(event) + '\n').join('')
await appendFile(path(session), lines)
}
ctx.on('session/flush', flush)
ctx.effect(() => () => {
// drain remaining buffers on dispose
for (const session of buffers.keys()) void flush(session)
}, 'session-jsonl')
}