fix: collapse session fork to one api

This commit is contained in:
Hypatia May
2026-07-06 13:57:59 +08:00
parent 37f3aedc0b
commit 37aac7f313
10 changed files with 284 additions and 177 deletions

View File

@@ -9,8 +9,7 @@ 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.fork({ source, boundary?, childSessionId? }): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that selected prefix to be turn-enclosed, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -69,9 +68,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. 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.
- 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.fork({ source, boundary?, childSessionId? })`, where `boundary` is the inclusive source event seq to fork through.
- 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 turn-boundary `snapshot()` / `fork()`.
- **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`.

View File

@@ -321,35 +321,24 @@ 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. */
/** Inputs for live session forking. */
export interface ForkSessionOptions {
/** Live source session object or id. */
source: SessionForkSource
/**
* Inclusive source event seq to fork through. Omitted means the source's
* current last event; omitted on an empty source forks an empty child.
*/
boundary?: number
/** Optional child session id; omitted delegates to SessionStore's id policy. */
sessionId?: SessionId
childSessionId?: SessionId
}
export type SessionForkErrorCode =
| 'SESSION_NOT_FOUND'
| 'SESSION_NOT_LIVE'
| 'SESSION_ALREADY_EXISTS'
| 'INVALID_BOUNDARY'
| 'OPEN_TURN'
/** Typed error for session fork rejections. */
@@ -496,47 +485,67 @@ export class SessionStore extends Service {
}
/**
* 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.
* Create a live child session from a turn-enclosed prefix of a live source.
* `boundary` is an inclusive source event seq; omitted means the source's
* current last event. A non-empty selected slice must be turn-enclosed and end
* at `turn/end`; this rejects open turns rather than clipping silently.
*
* @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.
* @param options Source, optional boundary, and optional child 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')
if (options.childSessionId !== undefined && this.get(options.childSessionId) !== undefined) {
throw new SessionForkError(`session "${options.childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS')
}
const snapshot = this.snapshot(options.source)
return this.create(options.sessionId, {
seed: snapshot.seed,
meta: snapshot.meta,
const source = this._resolveForkSource(options.source)
const seed = this._forkSeed(source, options.boundary)
return this.create(options.childSessionId, {
seed,
meta: {
...source.header.cwd !== undefined ? { cwd: source.header.cwd } : {},
parentSession: source.id,
seedLength: seed.length,
},
})
}
private _forkSeed(session: Session, requestedBoundary: number | undefined): SessionEvent[] {
const events = session.events
const lastEvent = events.at(-1)
let boundary: number
if (requestedBoundary !== undefined) {
boundary = requestedBoundary
} else {
if (lastEvent === undefined) return []
boundary = lastEvent.seq
}
if (!Number.isSafeInteger(boundary) || boundary < 0) {
throw new SessionForkError(
`fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`,
'INVALID_BOUNDARY',
)
}
if (boundary >= events.length) {
const lastSeq = events.at(-1)?.seq
throw new SessionForkError(
`fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`,
'INVALID_BOUNDARY',
)
}
const boundaryEvent = events[boundary]
if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
throw new SessionForkError(
`fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`,
'INVALID_BOUNDARY',
)
}
const seed = events.slice(0, boundary + 1)
this._assertForkBoundary(session, seed, boundary)
return seed.map(event => structuredClone(event))
}
private _resolveForkSource(source: SessionForkSource): Session {
if (typeof source === 'string') {
const session = this.get(source)
@@ -552,11 +561,46 @@ export class SessionStore extends Service {
return source
}
private _assertForkBoundary(session: Session): void {
const last = session.events.at(-1)
if (last !== undefined && last.type !== 'turn/end') {
private _assertForkBoundary(session: Session, seed: readonly SessionEvent[], boundary: number): void {
let openTurn: SessionEvent<'turn/start'> | undefined
for (const event of seed) {
switch (event.type) {
case 'turn/start': {
if (openTurn !== undefined) {
throw new SessionForkError(
`cannot fork session "${session.id}" at boundary ${boundary}: turn ${event.data.turn} starts before turn ${openTurn.data.turn} ended`,
'OPEN_TURN',
)
}
openTurn = event
break
}
case 'turn/end': {
if (openTurn === undefined) {
throw new SessionForkError(
`cannot fork session "${session.id}" at boundary ${boundary}: turn/end at seq ${event.seq} has no matching turn/start`,
'OPEN_TURN',
)
}
openTurn = undefined
break
}
default: {
if (openTurn === undefined) {
throw new SessionForkError(
`cannot fork session "${session.id}" at boundary ${boundary}: event ${event.seq} (${event.type}) is outside a turn`,
'OPEN_TURN',
)
}
break
}
}
}
const last = seed.at(-1)
if (openTurn !== undefined || last?.type !== 'turn/end') {
throw new SessionForkError(
`cannot fork session "${session.id}" inside an open turn (last event: ${last.type})`,
`cannot fork session "${session.id}" at boundary ${boundary}: slice ends inside an open turn (last event: ${last?.type ?? 'none'})`,
'OPEN_TURN',
)
}

View File

@@ -10,13 +10,26 @@ async function setup(): Promise<{ ctx: Context; sessions: SessionStore }> {
return { ctx, sessions: ctx.sessions }
}
function appendClosedTurn(session: Session, reason: TurnEndReason = { kind: 'completed' }): void {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
function appendClosedTurn(
session: Session,
turn: number,
text = `hello ${turn}`,
reason: TurnEndReason = { kind: 'completed' },
): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', {
content: [{ type: 'text', text: 'hello' }],
content: [{ type: 'text', text }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
session.append('turn/end', { turn, reason })
}
function appendOpenTurn(session: Session, turn: number): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', {
content: [{ type: 'text', text: `open ${turn}` }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason })
}
function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/message'> {
@@ -25,43 +38,68 @@ function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/m
return event
}
describe('SessionStore fork helpers', () => {
it('snapshots an empty live session as an empty seed with lineage metadata', async () => {
function lastSeq(session: Session): number {
const event = session.events.at(-1)
if (event === undefined) throw new Error('missing last event')
return event.seq
}
describe('SessionStore.fork', () => {
it('forks an empty live session as an empty child with lineage metadata', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } })
const snapshot = sessions.snapshot(source)
const child = sessions.fork({ source, childSessionId: SessionId('empty-child') })
expect(snapshot.source).toBe(source)
expect(snapshot.seed).toEqual([])
expect(snapshot.meta).toEqual({
expect(child.events).toEqual([])
expect(child.header).toMatchObject({
id: SessionId('empty-child'),
cwd: '/workspace',
parentSession: SessionId('empty-parent'),
seedLength: 0,
})
})
it('snapshots a completed boundary by live session id and deep-clones seed events', async () => {
it('forks the latest completed boundary by default and deep-clones seed events', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
appendClosedTurn(source)
appendClosedTurn(source, 1, 'hello')
const snapshot = sessions.snapshot(SessionId('parent'))
const child = sessions.fork({ source: SessionId('parent'), childSessionId: SessionId('child') })
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(child.events).toEqual(source.events)
expect(child.events).not.toBe(source.events)
expect(child.events[1]).not.toBe(source.events[1])
firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' }
expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
expect(snapshot.meta).toEqual({
expect(child.header).toMatchObject({
id: SessionId('child'),
cwd: '/workspace',
parentSession: SessionId('parent'),
seedLength: source.events.length,
})
})
it('accepts every turn/end reason as a fork boundary', async () => {
it('forks from an earlier turn boundary even when the source currently has an open tail', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
appendClosedTurn(source, 1, 'first')
const firstBoundary = lastSeq(source)
appendClosedTurn(source, 2, 'second')
appendOpenTurn(source, 3)
const child = sessions.fork({
source,
boundary: firstBoundary,
childSessionId: SessionId('child-from-first'),
})
expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1))
expect(child.header.seedLength).toBe(firstBoundary + 1)
expect(child.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'first' }] }])
})
it('accepts every turn/end reason as an explicit fork boundary', async () => {
const { ctx, sessions } = await setup()
const reasons: TurnEndReason[] = [
{ kind: 'completed' },
@@ -74,19 +112,42 @@ describe('SessionStore fork helpers', () => {
for (const reason of reasons) {
const source = ctx.sessions.create(SessionId(`parent-${reason.kind}`))
appendClosedTurn(source, reason)
appendClosedTurn(source, 1, reason.kind, reason)
const snapshot = sessions.snapshot(source)
const child = sessions.fork({
source,
boundary: lastSeq(source),
childSessionId: SessionId(`child-${reason.kind}`),
})
expect(snapshot.seed.at(-1)?.type).toBe('turn/end')
expect(snapshot.meta.seedLength).toBe(source.events.length)
expect(child.events.at(-1)?.type).toBe('turn/end')
expect(child.header.seedLength).toBe(source.events.length)
}
})
it('rejects invalid boundaries before creating a child', async () => {
const { ctx, sessions } = await setup()
const empty = ctx.sessions.create(SessionId('empty'))
expect(() => sessions.fork({ source: empty, boundary: 0, childSessionId: SessionId('empty-child') }))
.toThrow(new SessionForkError('fork boundary 0 does not exist in session "empty" (last seq: none)', 'INVALID_BOUNDARY'))
expect(ctx.sessions.get(SessionId('empty-child'))).toBeUndefined()
const source = ctx.sessions.create(SessionId('parent'))
appendClosedTurn(source, 1)
expect(() => sessions.fork({ source, boundary: -1, childSessionId: SessionId('negative') }))
.toThrow(/non-negative safe integer/)
expect(() => sessions.fork({ source, boundary: 0.5, childSessionId: SessionId('fraction') }))
.toThrow(/non-negative safe integer/)
expect(() => sessions.fork({ source, boundary: Number.MAX_SAFE_INTEGER + 1, childSessionId: SessionId('unsafe') }))
.toThrow(/non-negative safe integer/)
expect(() => sessions.fork({ source, boundary: source.seq, childSessionId: SessionId('past-end') }))
.toThrow(new SessionForkError(`fork boundary ${source.seq} does not exist in session "parent" (last seq: ${source.seq - 1})`, 'INVALID_BOUNDARY'))
})
it('rejects an unknown live session id', async () => {
const { sessions } = await setup()
expect(() => sessions.snapshot(SessionId('missing')))
expect(() => sessions.fork({ source: SessionId('missing') }))
.toThrow(new SessionForkError('session "missing" not found', 'SESSION_NOT_FOUND'))
})
@@ -94,7 +155,7 @@ describe('SessionStore fork helpers', () => {
const { sessions } = await setup()
const detached = new Session(SessionId('detached'))
expect(() => sessions.snapshot(detached))
expect(() => sessions.fork({ source: detached }))
.toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND'))
})
@@ -103,28 +164,32 @@ describe('SessionStore fork helpers', () => {
ctx.sessions.create(SessionId('same-id'))
const stale = new Session(SessionId('same-id'))
expect(() => sessions.snapshot(stale))
expect(() => sessions.fork({ source: 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 () => {
it('rejects selected slices whose boundary is inside an open turn', async () => {
const { ctx, sessions } = await setup()
const cases: [string, (session: Session) => void][] = [
const cases: [string, (session: Session) => number][] = [
['turn/start', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
return lastSeq(session)
}],
['step/start', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
return lastSeq(session)
}],
['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' })
return lastSeq(session)
}],
['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' })
return lastSeq(session)
}],
['tool/call', (session) => {
const callId = CallId('call-open')
@@ -136,51 +201,64 @@ describe('SessionStore fork helpers', () => {
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
return lastSeq(session)
}],
]
for (const [lastType, build] of cases) {
const source = ctx.sessions.create(SessionId(`open-${lastType}`))
build(source)
const boundary = build(source)
expect(() => sessions.snapshot(source))
.toThrow(new SessionForkError(`cannot fork session "open-${lastType}" inside an open turn (last event: ${lastType})`, 'OPEN_TURN'))
expect(() => sessions.fork({ source, boundary }))
.toThrow(new SessionForkError(`cannot fork session "open-${lastType}" at boundary ${boundary}: slice ends inside an open turn (last event: ${lastType})`, 'OPEN_TURN'))
}
})
it('creates a forked child session with the seed and lineage metadata', async () => {
it('rejects malformed turn enclosure in the selected slice', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
appendClosedTurn(source)
const outside = ctx.sessions.create(SessionId('outside'), {
seed: [
{ type: 'step/start', seq: 0, time: 1, data: { turn: 1, step: 1 } },
],
})
expect(() => sessions.fork({ source: outside, boundary: 0 }))
.toThrow(new SessionForkError('cannot fork session "outside" at boundary 0: event 0 (step/start) is outside a turn', 'OPEN_TURN'))
const child = sessions.fork({ source, sessionId: SessionId('child') })
const nested = ctx.sessions.create(SessionId('nested'), {
seed: [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 1, time: 2, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
],
})
expect(() => sessions.fork({ source: nested, boundary: 1 }))
.toThrow(new SessionForkError('cannot fork session "nested" at boundary 1: turn 2 starts before turn 1 ended', 'OPEN_TURN'))
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' }])
const orphanEnd = ctx.sessions.create(SessionId('orphan-end'), {
seed: [
{ type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
],
})
expect(() => sessions.fork({ source: orphanEnd, boundary: 0 }))
.toThrow(new SessionForkError('cannot fork session "orphan-end" at boundary 0: turn/end at seq 0 has no matching turn/start', 'OPEN_TURN'))
})
it('rejects a child session id that is already live with a typed fork error', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('parent'))
appendClosedTurn(source)
appendClosedTurn(source, 1)
ctx.sessions.create(SessionId('child'))
expect(() => sessions.fork({ source, sessionId: SessionId('child') }))
expect(() => sessions.fork({ source, childSessionId: 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 () => {
it('rejects a duplicate child session id before validating the boundary', async () => {
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(() => sessions.fork({ source, sessionId: SessionId('child') }))
expect(() => sessions.fork({ source, childSessionId: SessionId('child') }))
.toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS'))
})
})

View File

@@ -144,7 +144,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } })
appendClosedTurn(source)
const child = ctx.sessions.fork({ source, sessionId: SessionId('persist-child') })
const child = ctx.sessions.fork({ source, childSessionId: SessionId('persist-child') })
await ctx.parallel('session/flush', child)
const loaded = await ctx.sessionPersistence.load(child.id)