fix: fold session fork into session store

This commit is contained in:
Hypatia May
2026-07-06 12:35:34 +08:00
parent cf036299f3
commit 0bf8749128
23 changed files with 241 additions and 441 deletions

View File

@@ -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