feat: add session fork service

This commit is contained in:
Hypatia May
2026-06-30 12:53:46 +08:00
parent 3f85f522ea
commit da94bfd37c
18 changed files with 542 additions and 2 deletions

View File

@@ -14,6 +14,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam (backend + tool deferred) | Product — stable surface |
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | 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 |
| [`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) | Product — stable surface |
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations |
@@ -31,6 +32,7 @@ dsh-session ← dsh-llm, dsh-brand
dsh-system-prompt ← dsh-llm
dsh-agent ← dsh-llm, dsh-session, dsh-brand
dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; backend + tool deferred)
dsh-session-fork ← dsh-session (live-session fork snapshots + child session creation)
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
dsh-bash-local ← dsh-bash (BashExecutor impl)
dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas)
@@ -70,6 +72,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
| `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
| `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
| `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` |
| `session-fork/` | `session-fork` | Session fork service over live session seeds | `ctx.sessionFork` |
| `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
| `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) |
| `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` |

View File

@@ -0,0 +1,9 @@
# 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).

View File

@@ -0,0 +1,29 @@
# @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 is not the live store object for its id. |
| `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.

View File

@@ -0,0 +1,34 @@
{
"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"
}
}

View File

@@ -0,0 +1,128 @@
/**
* 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'
| '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.
*/
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.
*/
fork(options: ForkSessionOptions): Session {
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 !== source) {
throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND')
}
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

View File

@@ -0,0 +1,209 @@
import { afterEach, 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 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 }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionForkService)
return { ctx, fork: ctx.sessionFork }
}
function appendClosedTurn(session: Session, reason: TurnEndReason = { kind: 'completed' }): 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 })
}
function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/message'> {
const event = events.find((e): e is SessionEvent<'user/message'> => e.type === 'user/message')
if (event === undefined) throw new Error('missing user/message')
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()
})
it('snapshots an empty live session as an empty seed with lineage metadata', async () => {
const { ctx, fork } = await setup()
const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } })
const snapshot = fork.snapshot(source)
expect(snapshot.source).toBe(source)
expect(snapshot.seed).toEqual([])
expect(snapshot.meta).toEqual({
cwd: '/workspace',
parentSession: SessionId('empty-parent'),
seedLength: 0,
})
})
it('snapshots a completed boundary by live session id and deep-clones seed events', async () => {
const { ctx, fork } = await setup()
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
appendClosedTurn(source)
const snapshot = fork.snapshot(SessionId('parent'))
expect(snapshot.source).toBe(source)
expect(snapshot.seed).toEqual(source.events)
expect(snapshot.seed).not.toBe(source.events)
expect(snapshot.seed[1]).not.toBe(source.events[1])
firstUserMessage(snapshot.seed).data.content[0] = { type: 'text', text: 'mutated' }
expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
expect(snapshot.meta).toEqual({
cwd: '/workspace',
parentSession: SessionId('parent'),
seedLength: source.events.length,
})
})
it('accepts every turn/end reason as a fork boundary', async () => {
const { ctx, fork } = await setup()
const reasons: TurnEndReason[] = [
{ kind: 'completed' },
{ kind: 'aborted', reason: 'cancelled by user' },
{ kind: 'error', step: 1, message: 'model failed', code: 'MODEL' },
{ kind: 'disposed' },
{ kind: 'max-tokens' },
{ kind: 'interrupted' },
]
for (const reason of reasons) {
const source = ctx.sessions.create(SessionId(`parent-${reason.kind}`))
appendClosedTurn(source, reason)
const snapshot = fork.snapshot(source)
expect(snapshot.seed.at(-1)?.type).toBe('turn/end')
expect(snapshot.meta.seedLength).toBe(source.events.length)
}
})
it('rejects an unknown live session id', async () => {
const { fork } = await setup()
expect(() => fork.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 detached = new Session(SessionId('detached'))
expect(() => fork.snapshot(detached))
.toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND'))
})
it('rejects non-empty logs whose last event is not turn/end', async () => {
const { ctx, fork } = await setup()
const cases: [string, (session: Session) => void][] = [
['turn/start', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
}],
['step/start', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
}],
['user/message', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'open' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
}],
['assistant/message', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'partial' }] }, { surfaceOp: 'append' })
}],
['tool/call', (session) => {
const callId = CallId('call-open')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
}],
]
for (const [lastType, build] of cases) {
const source = ctx.sessions.create(SessionId(`open-${lastType}`))
build(source)
expect(() => fork.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 source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
appendClosedTurn(source)
const child = fork.fork({ source, sessionId: SessionId('child') })
expect(child.id).toBe(SessionId('child'))
expect(child.events).toEqual(source.events)
expect(child.header.parentSession).toBe(source.id)
expect(child.header.seedLength).toBe(source.events.length)
expect(child.header.cwd).toBe('/workspace')
firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' }
expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
})
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()
})
})

View File

@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/session"
}
]
}