From c7fba41ba969f1f0e905fe2bb28768e8c88595ac Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 7 Jul 2026 09:04:49 +0800 Subject: [PATCH] Inline session fork parameters --- docs/architecture.md | 2 +- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/session.md | 2 +- .../2026-06-30-session-store-fork-api.md | 8 +--- packages/core/session/README.md | 4 +- packages/core/session/src/index.ts | 36 +++++++---------- packages/core/session/tests/fork.spec.ts | 40 ++++++++----------- .../tests/jsonl.spec.ts | 2 +- 8 files changed, 38 insertions(+), 60 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index afe262e52c..371b5df579 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -143,6 +143,6 @@ New behavior should attach to a documented seam; changing the shipped loop requi | Intercept prompts, requests, tool use, or continuation | listen on the relevant `agent/*` or `tools/*` waterfall | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | | Add durable session state | add a `SessionEventMap` member and render/replay from the log | -| Fork a live session | use `ctx.sessions.fork({ source, boundary?, childSessionId? })` | +| Fork a live session | use `ctx.sessions.fork(source, boundary?, childSessionId?)` | The [extension cookbook](cookbook/extension-cookbook.md) carries plugin skeletons and the feature-to-seam map; step-by-step guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [vendored packages](cookbook/adding-a-vendored-package.md). diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d4aca35c5f..16434225d6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -161,10 +161,10 @@ enter(session: Session): () => void announce(session: Session): void get(id: SessionId): Session | undefined list(): Session[] -fork(options: ForkSessionOptions): Session +fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:402`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:389`](../../packages/core/session/src/index.ts) ## `ctx.subagents` — `SubagentService` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index c2ee9f0a12..b7cdbaf2c2 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -204,7 +204,7 @@ Everything else (`turn/*`, `step/*`) is structural and does not project into a m `ctx.sessions.create(id, { seed, meta })` is the low-level replay/fork primitive. For ordinary live-session forks, `SessionStore` exposes one policy API: -- `fork({ source, boundary?, childSessionId? })` accepts a live `Session` object or live `SessionId`, selects source events through the inclusive `boundary` seq (default: current last event), requires the boundary event to be `turn/end`, then creates a live child session with deep-cloned seed events plus child metadata (`parentSession`, `seedLength`, and inherited `cwd`). +- `fork(source, boundary?, childSessionId?)` accepts a live `Session` object or live `SessionId`, selects source events through the inclusive `boundary` seq (default: current last event), requires the boundary event to be `turn/end`, then creates a live child session with deep-cloned seed events plus child metadata (`parentSession`, `seedLength`, and inherited `cwd`). An explicit `boundary` lets callers fork from a previous completed turn even if the source has newer events or an open current turn. The API rejects non-`turn/end` boundaries instead of clipping silently. Broader turn-enclosure sanity stays in the existing `dsh-invariants` plugin and persistence repair path rather than being duplicated in `fork()`. `dsh-subagent-fork` keeps its completed-prefix clipping because tool-time delegation usually starts while the parent turn is open; ordinary session branching should make the requested boundary explicit. diff --git a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md index a1eac1baec..bf366afc02 100644 --- a/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md +++ b/docs/rfc/implemented/feature/2026-06-30-session-store-fork-api.md @@ -17,14 +17,8 @@ The store exposes one operation: ```ts ignore-check type SessionForkSource = Session | SessionId -interface ForkSessionOptions { - source: SessionForkSource - boundary?: number - childSessionId?: SessionId -} - class SessionStore extends Service { - fork(options: ForkSessionOptions): Session + fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session } ``` diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 49813a7950..0c0d60e26a 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -9,7 +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.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 boundary to be `turn/end`, and create 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 boundary to be `turn/end`, and create a live child session with lineage metadata. - `ctx.sessions.get(id: SessionId): Session | undefined` - `ctx.sessions.list(): Session[]` @@ -73,7 +73,7 @@ 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 always-on invariants `append` enforces — contiguous seqs, JSON-serializable data, and required `surfaceOp` markers on surface-eligible events — so marker-less message events are rejected at construction rather than silently vanishing from `deriveMessages()`. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork({ source, boundary?, childSessionId? })`, where `boundary` is the inclusive source event seq to fork through. +- 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 always-on invariants `append` enforces — contiguous seqs, JSON-serializable data, and required `surfaceOp` markers on surface-eligible events — so marker-less message events are rejected at construction rather than silently vanishing from `deriveMessages()`. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. 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) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index ffc7eaaacf..75708f5017 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -365,19 +365,6 @@ export class Session { /** A fork source: either the live session object or its live store id. */ export type SessionForkSource = Session | SessionId -/** 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. */ - childSessionId?: SessionId -} - export type SessionForkErrorCode = | 'SESSION_NOT_FOUND' | 'SESSION_NOT_LIVE' @@ -533,20 +520,25 @@ export class SessionStore extends Service { * `boundary` is an inclusive source event seq; omitted means the source's * current last event. A non-empty selected slice must end at `turn/end`. * - * @param options Source, optional boundary, and optional child id for the fork. + * @param source - Live source session object or id. + * @param boundary - Inclusive source event seq to fork through; omitted means + * the source's current last event, and omitted on an empty source forks an + * empty child. + * @param childSessionId - Optional child session id; omitted delegates to + * `SessionStore`'s id policy. * @returns The created live child session. */ - fork(options: ForkSessionOptions): Session { - if (options.childSessionId !== undefined && this.get(options.childSessionId) !== undefined) { - throw new SessionForkError(`session "${options.childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS') + fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session { + if (childSessionId !== undefined && this.get(childSessionId) !== undefined) { + throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS') } - const source = this._resolveForkSource(options.source) - const seed = this._forkSeed(source, options.boundary) - return this.create(options.childSessionId, { + const liveSource = this._resolveForkSource(source) + const seed = this._forkSeed(liveSource, boundary) + return this.create(childSessionId, { seed, meta: { - ...source.header.cwd !== undefined ? { cwd: source.header.cwd } : {}, - parentSession: source.id, + ...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {}, + parentSession: liveSource.id, seedLength: seed.length, }, }) diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 46c9ef71ba..25328294cf 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -49,7 +49,7 @@ describe('SessionStore.fork', () => { const { ctx, sessions } = await setup() const source = ctx.sessions.create(SessionId('empty-parent'), { meta: { cwd: '/workspace' } }) - const child = sessions.fork({ source, childSessionId: SessionId('empty-child') }) + const child = sessions.fork(source, undefined, SessionId('empty-child')) expect(child.events).toEqual([]) expect(child.header).toMatchObject({ @@ -65,7 +65,7 @@ describe('SessionStore.fork', () => { const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } }) appendClosedTurn(source, 1, 'hello') - const child = sessions.fork({ source: SessionId('parent'), childSessionId: SessionId('child') }) + const child = sessions.fork(SessionId('parent'), undefined, SessionId('child')) expect(child.events).toEqual(source.events) expect(child.events).not.toBe(source.events) @@ -88,11 +88,7 @@ describe('SessionStore.fork', () => { appendClosedTurn(source, 2, 'second') appendOpenTurn(source, 3) - const child = sessions.fork({ - source, - boundary: firstBoundary, - childSessionId: SessionId('child-from-first'), - }) + const child = sessions.fork(source, firstBoundary, SessionId('child-from-first')) expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1)) expect(child.header.seedLength).toBe(firstBoundary + 1) @@ -114,11 +110,7 @@ describe('SessionStore.fork', () => { const source = ctx.sessions.create(SessionId(`parent-${reason.kind}`)) appendClosedTurn(source, 1, reason.kind, reason) - const child = sessions.fork({ - source, - boundary: lastSeq(source), - childSessionId: SessionId(`child-${reason.kind}`), - }) + const child = sessions.fork(source, lastSeq(source), SessionId(`child-${reason.kind}`)) expect(child.events.at(-1)?.type).toBe('turn/end') expect(child.header.seedLength).toBe(source.events.length) @@ -128,19 +120,19 @@ describe('SessionStore.fork', () => { 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') })) + expect(() => sessions.fork(empty, 0, 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') })) + expect(() => sessions.fork(source, -1, SessionId('negative'))) .toThrow(/non-negative safe integer/) - expect(() => sessions.fork({ source, boundary: 0.5, childSessionId: SessionId('fraction') })) + expect(() => sessions.fork(source, 0.5, SessionId('fraction'))) .toThrow(/non-negative safe integer/) - expect(() => sessions.fork({ source, boundary: Number.MAX_SAFE_INTEGER + 1, childSessionId: SessionId('unsafe') })) + expect(() => sessions.fork(source, Number.MAX_SAFE_INTEGER + 1, SessionId('unsafe'))) .toThrow(/non-negative safe integer/) - expect(() => sessions.fork({ source, boundary: source.seq, childSessionId: SessionId('past-end') })) + expect(() => sessions.fork(source, source.seq, SessionId('past-end'))) .toThrow(new SessionForkError(`fork boundary ${source.seq} does not exist in session "parent" (last seq: ${source.seq - 1})`, 'INVALID_BOUNDARY')) }) @@ -151,7 +143,7 @@ describe('SessionStore.fork', () => { const mutableLog = (source as unknown as { log: SessionEvent[] }).log mutableLog[2] = { ...mutableLog[2]!, seq: 99 } - expect(() => sessions.fork({ source, boundary: 2, childSessionId: SessionId('corrupt-child') })) + expect(() => sessions.fork(source, 2, SessionId('corrupt-child'))) .toThrow(new SessionForkError('fork boundary 2 does not match a contiguous event seq in session "corrupt-parent"', 'INVALID_BOUNDARY')) expect(ctx.sessions.get(SessionId('corrupt-child'))).toBeUndefined() }) @@ -159,7 +151,7 @@ describe('SessionStore.fork', () => { it('rejects an unknown live session id', async () => { const { sessions } = await setup() - expect(() => sessions.fork({ source: SessionId('missing') })) + expect(() => sessions.fork(SessionId('missing'))) .toThrow(new SessionForkError('session "missing" not found', 'SESSION_NOT_FOUND')) }) @@ -167,7 +159,7 @@ describe('SessionStore.fork', () => { const { sessions } = await setup() const detached = new Session(SessionId('detached')) - expect(() => sessions.fork({ source: detached })) + expect(() => sessions.fork(detached)) .toThrow(new SessionForkError('session "detached" not found', 'SESSION_NOT_FOUND')) }) @@ -176,7 +168,7 @@ describe('SessionStore.fork', () => { ctx.sessions.create(SessionId('same-id')) const stale = new Session(SessionId('same-id')) - expect(() => sessions.fork({ source: stale })) + expect(() => sessions.fork(stale)) .toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE')) }) @@ -221,7 +213,7 @@ describe('SessionStore.fork', () => { const source = ctx.sessions.create(SessionId(`open-${lastType}`)) const boundary = build(source) - expect(() => sessions.fork({ source, boundary })) + expect(() => sessions.fork(source, boundary)) .toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" must be turn/end, got ${lastType}`, 'OPEN_TURN')) } }) @@ -232,7 +224,7 @@ describe('SessionStore.fork', () => { appendClosedTurn(source, 1) ctx.sessions.create(SessionId('child')) - expect(() => sessions.fork({ source, childSessionId: SessionId('child') })) + expect(() => sessions.fork(source, undefined, SessionId('child'))) .toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS')) }) @@ -242,7 +234,7 @@ describe('SessionStore.fork', () => { source.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) ctx.sessions.create(SessionId('child')) - expect(() => sessions.fork({ source, childSessionId: SessionId('child') })) + expect(() => sessions.fork(source, undefined, SessionId('child'))) .toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS')) }) }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index b40ad0d0b6..6eed63a2f4 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -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, childSessionId: SessionId('persist-child') }) + const child = ctx.sessions.fork(source, undefined, SessionId('persist-child')) await ctx.parallel('session/flush', child) const loaded = await ctx.sessionPersistence.load(child.id)