fix: fold session fork into session store
This commit is contained in:
@@ -16,7 +16,6 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
|
||||
| [`session-fork/`](session-fork/README.md) | Session fork capability family: live-session fork snapshots and child session creation | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) + the app packages | Product — stable surface |
|
||||
|
||||
@@ -9,6 +9,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
### Public API
|
||||
|
||||
- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber.
|
||||
- `ctx.sessions.snapshot(source: Session | SessionId): SessionForkSeed` — Resolve a live session object or id, reject non-boundary logs, and return a deep-cloned seed plus `parentSession` / `seedLength` metadata. Use this when the caller will pass the seed/meta into another creation path instead of creating a detached session immediately.
|
||||
- `ctx.sessions.fork({ source, sessionId? }): Session` — Convenience wrapper around `snapshot(source)` + `create(sessionId, { seed, meta })`; creates a live child session with lineage metadata.
|
||||
- `ctx.sessions.get(id: SessionId): Session | undefined`
|
||||
- `ctx.sessions.list(): Session[]`
|
||||
|
||||
@@ -67,9 +69,9 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
### Extension points
|
||||
|
||||
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
|
||||
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME invariants `append` enforces — including that every surface-eligible event (`SurfaceEventType`) carries a `surfaceOp` marker — so a marker-less message event is rejected at construction rather than silently vanishing from `deriveMessages()` (the surface is the sole derivation path) on resume.
|
||||
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME invariants `append` enforces — including that every surface-eligible event (`SurfaceEventType`) carries a `surfaceOp` marker — so a marker-less message event is rejected at construction rather than silently vanishing from `deriveMessages()` (the surface is the sole derivation path) on resume. Ordinary live-session forks use `ctx.sessions.snapshot()` to validate an empty or `turn/end` boundary and build reusable seed metadata, or `ctx.sessions.fork()` to create the child session immediately.
|
||||
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint.
|
||||
|
||||
### What is NOT here (TODO)
|
||||
|
||||
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond seed-based forking.
|
||||
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond turn-boundary `snapshot()` / `fork()`.
|
||||
|
||||
@@ -318,6 +318,48 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/** A fork source: either the live session object or its live store id. */
|
||||
export type SessionForkSource = Session | SessionId
|
||||
|
||||
/** Metadata and seed events that can create a forked child session or agent. */
|
||||
export interface SessionForkSeed {
|
||||
/** The resolved live source session. */
|
||||
source: Session
|
||||
/** Deep-cloned seed events copied from the source session at a turn boundary. */
|
||||
seed: SessionEvent[]
|
||||
/** Session creation metadata for the forked child. */
|
||||
meta: {
|
||||
/** The source session id. */
|
||||
parentSession: SessionId
|
||||
/** How many leading child events were inherited rather than produced. */
|
||||
seedLength: number
|
||||
/** The source session workspace, inherited by the child when present. */
|
||||
cwd?: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Inputs for the convenience session-creation path. */
|
||||
export interface ForkSessionOptions {
|
||||
/** Live source session object or id. */
|
||||
source: SessionForkSource
|
||||
/** Optional child session id; omitted delegates to SessionStore's id policy. */
|
||||
sessionId?: SessionId
|
||||
}
|
||||
|
||||
export type SessionForkErrorCode =
|
||||
| 'SESSION_NOT_FOUND'
|
||||
| 'SESSION_NOT_LIVE'
|
||||
| 'SESSION_ALREADY_EXISTS'
|
||||
| 'OPEN_TURN'
|
||||
|
||||
/** Typed error for session fork rejections. */
|
||||
export class SessionForkError extends Error {
|
||||
constructor(message: string, public readonly code: SessionForkErrorCode) {
|
||||
super(message)
|
||||
this.name = 'SessionForkError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory session store (`ctx.sessions`).
|
||||
*
|
||||
@@ -452,6 +494,73 @@ export class SessionStore extends Service {
|
||||
list(): Session[] {
|
||||
return [...this.store.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and validate a live source session, then return a reusable deep-
|
||||
* cloned fork seed. A non-empty source must end exactly at `turn/end`; this
|
||||
* rejects open turns rather than clipping to an older boundary.
|
||||
*
|
||||
* @param source Live session object or live store id to snapshot.
|
||||
* @returns Deep-cloned seed events plus child session metadata.
|
||||
*/
|
||||
snapshot(source: SessionForkSource): SessionForkSeed {
|
||||
const session = this._resolveForkSource(source)
|
||||
this._assertForkBoundary(session)
|
||||
const seed = session.events.map(event => structuredClone(event))
|
||||
return {
|
||||
source: session,
|
||||
seed,
|
||||
meta: {
|
||||
...session.header.cwd !== undefined ? { cwd: session.header.cwd } : {},
|
||||
parentSession: session.id,
|
||||
seedLength: seed.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience path: create a live child session from a fork snapshot. Callers
|
||||
* that create agents can use {@link snapshot} and pass its seed/meta through
|
||||
* `ctx.agents.create` instead.
|
||||
*
|
||||
* @param options Source and optional child session id for the fork.
|
||||
* @returns The created live child session.
|
||||
*/
|
||||
fork(options: ForkSessionOptions): Session {
|
||||
if (options.sessionId !== undefined && this.get(options.sessionId) !== undefined) {
|
||||
throw new SessionForkError(`session "${options.sessionId}" already exists`, 'SESSION_ALREADY_EXISTS')
|
||||
}
|
||||
const snapshot = this.snapshot(options.source)
|
||||
return this.create(options.sessionId, {
|
||||
seed: snapshot.seed,
|
||||
meta: snapshot.meta,
|
||||
})
|
||||
}
|
||||
|
||||
private _resolveForkSource(source: SessionForkSource): Session {
|
||||
if (typeof source === 'string') {
|
||||
const session = this.get(source)
|
||||
if (session === undefined) throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND')
|
||||
return session
|
||||
}
|
||||
|
||||
const live = this.get(source.id)
|
||||
if (live === undefined) {
|
||||
throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND')
|
||||
}
|
||||
if (live !== source) throw new SessionForkError(`session "${source.id}" is not the live store instance`, 'SESSION_NOT_LIVE')
|
||||
return source
|
||||
}
|
||||
|
||||
private _assertForkBoundary(session: Session): void {
|
||||
const last = session.events.at(-1)
|
||||
if (last !== undefined && last.type !== 'turn/end') {
|
||||
throw new SessionForkError(
|
||||
`cannot fork session "${session.id}" inside an open turn (last event: ${last.type})`,
|
||||
'OPEN_TURN',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SessionStore
|
||||
|
||||
@@ -1,31 +1,13 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import SessionForkService, { SessionForkError } from '../src/index.ts'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function tempRoot(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-session-fork-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function setup(): Promise<{ ctx: Context; fork: SessionForkService }> {
|
||||
async function setup(): Promise<{ ctx: Context; sessions: SessionStore }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionForkService)
|
||||
return { ctx, fork: ctx.sessionFork }
|
||||
return { ctx, sessions: ctx.sessions }
|
||||
}
|
||||
|
||||
function appendClosedTurn(session: Session, reason: TurnEndReason = { kind: 'completed' }): void {
|
||||
@@ -43,23 +25,12 @@ function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/m
|
||||
return event
|
||||
}
|
||||
|
||||
describe('SessionForkService', () => {
|
||||
it('registers as ctx.sessionFork and unregisters on fiber disposal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionForkService)
|
||||
expect(ctx.sessionFork).toBeInstanceOf(SessionForkService)
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
expect(ctx.sessionFork).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('SessionStore fork helpers', () => {
|
||||
it('snapshots an empty live session as an empty seed with lineage metadata', async () => {
|
||||
const { ctx, fork } = await setup()
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } })
|
||||
|
||||
const snapshot = fork.snapshot(source)
|
||||
const snapshot = sessions.snapshot(source)
|
||||
|
||||
expect(snapshot.source).toBe(source)
|
||||
expect(snapshot.seed).toEqual([])
|
||||
@@ -71,11 +42,11 @@ describe('SessionForkService', () => {
|
||||
})
|
||||
|
||||
it('snapshots a completed boundary by live session id and deep-clones seed events', async () => {
|
||||
const { ctx, fork } = await setup()
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
|
||||
appendClosedTurn(source)
|
||||
|
||||
const snapshot = fork.snapshot(SessionId('parent'))
|
||||
const snapshot = sessions.snapshot(SessionId('parent'))
|
||||
|
||||
expect(snapshot.source).toBe(source)
|
||||
expect(snapshot.seed).toEqual(source.events)
|
||||
@@ -91,7 +62,7 @@ describe('SessionForkService', () => {
|
||||
})
|
||||
|
||||
it('accepts every turn/end reason as a fork boundary', async () => {
|
||||
const { ctx, fork } = await setup()
|
||||
const { ctx, sessions } = await setup()
|
||||
const reasons: TurnEndReason[] = [
|
||||
{ kind: 'completed' },
|
||||
{ kind: 'aborted', reason: 'cancelled by user' },
|
||||
@@ -105,7 +76,7 @@ describe('SessionForkService', () => {
|
||||
const source = ctx.sessions.create(SessionId(`parent-${reason.kind}`))
|
||||
appendClosedTurn(source, reason)
|
||||
|
||||
const snapshot = fork.snapshot(source)
|
||||
const snapshot = sessions.snapshot(source)
|
||||
|
||||
expect(snapshot.seed.at(-1)?.type).toBe('turn/end')
|
||||
expect(snapshot.meta.seedLength).toBe(source.events.length)
|
||||
@@ -113,31 +84,31 @@ describe('SessionForkService', () => {
|
||||
})
|
||||
|
||||
it('rejects an unknown live session id', async () => {
|
||||
const { fork } = await setup()
|
||||
const { sessions } = await setup()
|
||||
|
||||
expect(() => fork.snapshot(SessionId('missing')))
|
||||
expect(() => sessions.snapshot(SessionId('missing')))
|
||||
.toThrow(new SessionForkError('session "missing" not found', 'SESSION_NOT_FOUND'))
|
||||
})
|
||||
|
||||
it('rejects a detached Session object that is not live in ctx.sessions', async () => {
|
||||
const { fork } = await setup()
|
||||
const { sessions } = await setup()
|
||||
const detached = new Session(SessionId('detached'))
|
||||
|
||||
expect(() => fork.snapshot(detached))
|
||||
expect(() => sessions.snapshot(detached))
|
||||
.toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND'))
|
||||
})
|
||||
|
||||
it('rejects a stale Session object whose id is live on a different instance', async () => {
|
||||
const { ctx, fork } = await setup()
|
||||
const { ctx, sessions } = await setup()
|
||||
ctx.sessions.create(SessionId('same-id'))
|
||||
const stale = new Session(SessionId('same-id'))
|
||||
|
||||
expect(() => fork.snapshot(stale))
|
||||
expect(() => sessions.snapshot(stale))
|
||||
.toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE'))
|
||||
})
|
||||
|
||||
it('rejects non-empty logs whose last event is not turn/end', async () => {
|
||||
const { ctx, fork } = await setup()
|
||||
const { ctx, sessions } = await setup()
|
||||
const cases: [string, (session: Session) => void][] = [
|
||||
['turn/start', (session) => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
@@ -172,17 +143,17 @@ describe('SessionForkService', () => {
|
||||
const source = ctx.sessions.create(SessionId(`open-${lastType}`))
|
||||
build(source)
|
||||
|
||||
expect(() => fork.snapshot(source))
|
||||
expect(() => sessions.snapshot(source))
|
||||
.toThrow(new SessionForkError(`cannot fork session "open-${lastType}" inside an open turn (last event: ${lastType})`, 'OPEN_TURN'))
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a forked child session with the seed and lineage metadata', async () => {
|
||||
const { ctx, fork } = await setup()
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
|
||||
appendClosedTurn(source)
|
||||
|
||||
const child = fork.fork({ source, sessionId: SessionId('child') })
|
||||
const child = sessions.fork({ source, sessionId: SessionId('child') })
|
||||
|
||||
expect(child.id).toBe(SessionId('child'))
|
||||
expect(child.events).toEqual(source.events)
|
||||
@@ -194,45 +165,22 @@ describe('SessionForkService', () => {
|
||||
})
|
||||
|
||||
it('rejects a child session id that is already live with a typed fork error', async () => {
|
||||
const { ctx, fork } = await setup()
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('parent'))
|
||||
appendClosedTurn(source)
|
||||
ctx.sessions.create(SessionId('child'))
|
||||
|
||||
expect(() => fork.fork({ source, sessionId: SessionId('child') }))
|
||||
expect(() => sessions.fork({ source, sessionId: SessionId('child') }))
|
||||
.toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS'))
|
||||
})
|
||||
|
||||
it('rejects a duplicate child session id before validating the source boundary', async () => {
|
||||
const { ctx, fork } = await setup()
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('open-parent'))
|
||||
source.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
ctx.sessions.create(SessionId('child'))
|
||||
|
||||
expect(() => fork.fork({ source, sessionId: SessionId('child') }))
|
||||
expect(() => sessions.fork({ source, sessionId: SessionId('child') }))
|
||||
.toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS'))
|
||||
})
|
||||
|
||||
it('persists a forked child seed through the existing session write path', async () => {
|
||||
const root = await tempRoot()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionForkService)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } })
|
||||
appendClosedTurn(source)
|
||||
|
||||
const child = ctx.sessionFork.fork({ source, sessionId: SessionId('persist-child') })
|
||||
await ctx.parallel('session/flush', child)
|
||||
const loaded = await ctx.sessionPersistence.load(child.id)
|
||||
|
||||
expect(loaded.events).toEqual(source.events)
|
||||
expect(loaded.meta).toMatchObject({
|
||||
id: SessionId('persist-child'),
|
||||
cwd: '/workspace',
|
||||
parentSession: SessionId('persist-parent'),
|
||||
seedLength: source.events.length,
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +0,0 @@
|
||||
# session-fork/ — session fork capability family
|
||||
|
||||
The session fork capability: a small optional service that validates a live session is at a turn boundary, snapshots its event log as a seed, and creates forked child sessions through the existing `dsh-session` seed primitive. All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `session-fork/` | Session fork service: reusable seed snapshot + forked live-session creation | `ctx.sessionFork` |
|
||||
|
||||
The interface and implementation live together at `session-fork/session-fork/` because v1 has no swappable backend: all durable behavior is delegated to the existing session store and persistence backends. The decision is recorded in [the session fork service RFC](../../docs/rfc/implemented/feature/2026-06-30-session-fork-service.md).
|
||||
@@ -1,31 +0,0 @@
|
||||
# @deepseek-ai/dsh-session-fork
|
||||
|
||||
Session fork service (`ctx.sessionFork`) for creating seeded child sessions from a live source session at a turn boundary.
|
||||
|
||||
## Service: `SessionForkService`
|
||||
|
||||
`SessionForkService` is an optional plugin over `dsh-session`; it does not add session events or persistence methods. It owns fork policy, while `ctx.sessions.create(id, { seed, meta })` remains the low-level replay/fork primitive.
|
||||
|
||||
| Method | Purpose |
|
||||
|---|---|
|
||||
| `snapshot(source)` | Resolve a live `Session \| SessionId`, reject non-boundary logs, and return a deep-cloned seed plus `parentSession` / `seedLength` metadata. |
|
||||
| `fork({ source, sessionId? })` | Create a live child session from `snapshot(source)`, using the caller-supplied child id or the session store's generated id. |
|
||||
|
||||
## Boundary Rule
|
||||
|
||||
A source is forkable only when its log is empty or its last event is `turn/end`. The service accepts any turn-end reason, including `aborted`, `error`, `disposed`, `max-tokens`, and crash-repaired `interrupted`; the boundary is structural, not a statement that the prior turn was successful.
|
||||
|
||||
Forking inside a turn is rejected with `SessionForkError` code `OPEN_TURN`. The service intentionally does not clip to an older completed prefix; that behavior is specific to `dsh-subagent-fork`, where tool-time delegation normally happens while the parent turn is open.
|
||||
|
||||
## Errors
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `SESSION_NOT_FOUND` | A source id is not live in `ctx.sessions`, or a passed `Session` object's id is not live in the store. |
|
||||
| `SESSION_NOT_LIVE` | A passed `Session` object has a live id in the store, but it is not that live store instance. |
|
||||
| `SESSION_ALREADY_EXISTS` | The requested child `sessionId` is already live in `ctx.sessions`. |
|
||||
| `OPEN_TURN` | The source log is non-empty and does not end at `turn/end`. |
|
||||
|
||||
## Persistence
|
||||
|
||||
Forked sessions use existing session metadata: `parentSession` points to the source session id, `seedLength` is the number of inherited events, and `cwd` is inherited when present. Persistence backends observe the forked child through their existing `session/created` and `session/flush` write path, so no backend-specific fork API is needed.
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-fork",
|
||||
"description": "Session fork service for creating seeded child sessions at turn boundaries",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Session forking as an optional service. The core session store exposes the
|
||||
* low-level seed primitive; this plugin owns the policy for when a live session
|
||||
* may be forked and the metadata stamped on the child.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-fork
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sessionFork: SessionForkService
|
||||
}
|
||||
}
|
||||
|
||||
/** A fork source: either the live session object or its live store id. */
|
||||
export type SessionForkSource = Session | SessionId
|
||||
|
||||
/** Metadata and seed events that can create a forked child session or agent. */
|
||||
export interface SessionForkSeed {
|
||||
/** The resolved live source session. */
|
||||
source: Session
|
||||
/** Deep-cloned seed events copied from the source session at a turn boundary. */
|
||||
seed: SessionEvent[]
|
||||
/** Session creation metadata for the forked child. */
|
||||
meta: {
|
||||
/** The source session id. */
|
||||
parentSession: SessionId
|
||||
/** How many leading child events were inherited rather than produced. */
|
||||
seedLength: number
|
||||
/** The source session workspace, inherited by the child when present. */
|
||||
cwd?: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Inputs for the convenience session-creation path. */
|
||||
export interface ForkSessionOptions {
|
||||
/** Live source session object or id. */
|
||||
source: SessionForkSource
|
||||
/** Optional child session id; omitted delegates to SessionStore's id policy. */
|
||||
sessionId?: SessionId
|
||||
}
|
||||
|
||||
export type SessionForkErrorCode =
|
||||
| 'SESSION_NOT_FOUND'
|
||||
| 'SESSION_NOT_LIVE'
|
||||
| 'SESSION_ALREADY_EXISTS'
|
||||
| 'OPEN_TURN'
|
||||
|
||||
/** Typed error for service-level fork rejections. */
|
||||
export class SessionForkError extends Error {
|
||||
constructor(message: string, public readonly code: SessionForkErrorCode) {
|
||||
super(message)
|
||||
this.name = 'SessionForkError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.sessionFork`: validates live session fork boundaries and creates seeded
|
||||
* child sessions using the existing `ctx.sessions.create({ seed })` primitive.
|
||||
*/
|
||||
export class SessionForkService extends Service {
|
||||
static inject = ['sessions']
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'sessionFork')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and validate a live source session, then return a reusable deep-
|
||||
* cloned fork seed. A non-empty source must end exactly at `turn/end`; this
|
||||
* service rejects open turns rather than clipping to an older boundary.
|
||||
*
|
||||
* @param source Live session object or live store id to snapshot.
|
||||
* @returns Deep-cloned seed events plus child session metadata.
|
||||
*/
|
||||
snapshot(source: SessionForkSource): SessionForkSeed {
|
||||
const session = this._resolve(source)
|
||||
this._assertTurnBoundary(session)
|
||||
const seed = session.events.map(event => structuredClone(event))
|
||||
return {
|
||||
source: session,
|
||||
seed,
|
||||
meta: {
|
||||
...session.header.cwd !== undefined ? { cwd: session.header.cwd } : {},
|
||||
parentSession: session.id,
|
||||
seedLength: seed.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience path: create a live child session from a fork snapshot. Callers
|
||||
* that create agents can use {@link snapshot} and pass its seed/meta through
|
||||
* `ctx.agents.create` instead.
|
||||
*
|
||||
* @param options Source and optional child session id for the fork.
|
||||
* @returns The created live child session.
|
||||
*/
|
||||
fork(options: ForkSessionOptions): Session {
|
||||
if (options.sessionId !== undefined && this.ctx.sessions.get(options.sessionId) !== undefined) {
|
||||
throw new SessionForkError(`session "${options.sessionId}" already exists`, 'SESSION_ALREADY_EXISTS')
|
||||
}
|
||||
const snapshot = this.snapshot(options.source)
|
||||
return this.ctx.sessions.create(options.sessionId, {
|
||||
seed: snapshot.seed,
|
||||
meta: snapshot.meta,
|
||||
})
|
||||
}
|
||||
|
||||
private _resolve(source: SessionForkSource): Session {
|
||||
if (typeof source === 'string') {
|
||||
const session = this.ctx.sessions.get(source)
|
||||
if (session === undefined) throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND')
|
||||
return session
|
||||
}
|
||||
|
||||
const live = this.ctx.sessions.get(source.id)
|
||||
if (live === undefined) {
|
||||
throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND')
|
||||
}
|
||||
if (live !== source) throw new SessionForkError(`session "${source.id}" is not the live store instance`, 'SESSION_NOT_LIVE')
|
||||
return source
|
||||
}
|
||||
|
||||
private _assertTurnBoundary(session: Session): void {
|
||||
const last = session.events.at(-1)
|
||||
if (last !== undefined && last.type !== 'turn/end') {
|
||||
throw new SessionForkError(
|
||||
`cannot fork session "${session.id}" inside an open turn (last event: ${last.type})`,
|
||||
'OPEN_TURN',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SessionForkService
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23,6 +23,15 @@ afterEach(async () => {
|
||||
for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function appendClosedTurn(session: Session): void {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
// Run the shared backend contract against the real JSONL backend.
|
||||
runPersistenceContract('jsonl', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
|
||||
@@ -131,6 +140,23 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
expect(loaded.events).toEqual(log) // chunks preserved, contiguous seqs
|
||||
})
|
||||
|
||||
it('persists a forked child seed through the existing session write path', async () => {
|
||||
const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } })
|
||||
appendClosedTurn(source)
|
||||
|
||||
const child = ctx.sessions.fork({ source, sessionId: SessionId('persist-child') })
|
||||
await ctx.parallel('session/flush', child)
|
||||
const loaded = await ctx.sessionPersistence.load(child.id)
|
||||
|
||||
expect(loaded.events).toEqual(source.events)
|
||||
expect(loaded.meta).toMatchObject({
|
||||
id: SessionId('persist-child'),
|
||||
cwd: '/workspace',
|
||||
parentSession: SessionId('persist-parent'),
|
||||
seedLength: source.events.length,
|
||||
})
|
||||
})
|
||||
|
||||
it('crash recovery: load preserves the interrupted turn and closes it with a synthetic turn/end {interrupted}', async () => {
|
||||
const m = meta('crash', '/proj')
|
||||
await ctx.sessionPersistence.create(m)
|
||||
|
||||
Reference in New Issue
Block a user