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 changed files with 2959 additions and 89 deletions

View File

@@ -8,6 +8,7 @@
*/
import { Context, Service } from 'cordis'
import { randomUUID } from 'node:crypto'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { AgentOptions } from '@deepseek-ai/dsh-agent'
@@ -62,12 +63,24 @@ export class AgentLoop extends Service {
* Create an agent, start its loop, and register it. Returns the agent.
* Disposed with the calling fiber.
*
* The session id is per-run (`${id}-session-<uuid>`, no fixed name): once a
* durable persistence backend is loaded, a fixed `${id}-session` collides on
* the second run — the backend refuses to re-create an id whose log already
* exists on disk (the SessionId is the identity). A fresh id means each run
* is a new session.
*
* TODO(demo): each run starting a brand-new session is fine for demos but is
* NOT real conversation continuity. A production config-driven agent needs a
* deliberate resume-or-create policy (resume the prior session if one exists,
* else start fresh) or an explicit caller-chosen session id — revisit when the
* UI/ACP path owns session selection.
*
* TODO(sub-agents): spawn/fork land here — accept a parent agent reference;
* fork seeds the new Session with the parent's event log, spawn starts
* fresh; the child is returned as a regular Agent handle.
*/
create(id: string, options: AgentOptions = {}): LoopAgent {
const session = this.ctx.sessions.create(`${id}-session`)
const session = this.ctx.sessions.create(`${id}-session-${randomUUID()}`, { meta: {} })
const agent = new LoopAgent(this.ctx, AgentId(id), options, session)
// Generator effect: stop and unregister are independent disposables
// (LIFO), so a throwing stop() cannot leak the registry entry.