From 088860b80b3d19614d956da270289eecf4012127 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 30 Jun 2026 13:04:40 +0800 Subject: [PATCH] fix: classify session fork errors --- docs/cordis-catalog/events-and-services.md | 2 +- .../2026-06-30-session-fork-service.md | 2 +- packages/session-fork/session-fork/README.md | 4 +++- .../session-fork/session-fork/src/index.ts | 8 +++++++- .../session-fork/tests/session-fork.spec.ts | 19 +++++++++++++++++++ 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index 7b701a47c3..aa6e25441d 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -402,7 +402,7 @@ snapshot(source: SessionForkSource): SessionForkSeed fork(options: ForkSessionOptions): Session ``` -Source: [`packages/session-fork/session-fork/src/index.ts:63`](../../packages/session-fork/session-fork/src/index.ts) +Source: [`packages/session-fork/session-fork/src/index.ts:65`](../../packages/session-fork/session-fork/src/index.ts) ### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) diff --git a/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md b/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md index 6b59f0a9f6..716edc4e51 100644 --- a/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md +++ b/docs/rfc/implemented/feature/2026-06-30-session-fork-service.md @@ -44,7 +44,7 @@ class SessionForkService extends Service { `fork()` is the convenience half. It calls `snapshot()`, then creates a live child session via `ctx.sessions.create(sessionId, { seed, meta })`. The child inherits the source session's `cwd`, stamps `parentSession` to the source id, and sets `seedLength` to the seeded prefix length. When `sessionId` is omitted, `SessionStore` generates one using its existing id policy. -The boundary rule is structural: an empty source log is forkable, and any source whose last event is `turn/end` is forkable regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). Any non-empty source whose last event is not `turn/end` is inside a turn or otherwise not at the boundary and is rejected with a typed `SessionForkError` code. This is intentionally stricter than `dsh-subagent-fork`, whose completed-prefix clipping remains unchanged because it serves tool-time delegation rather than user/session branching. +The boundary rule is structural: an empty source log is forkable, and any source whose last event is `turn/end` is forkable regardless of the turn-end reason (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `interrupted`, or a future merge-extensible reason). Any non-empty source whose last event is not `turn/end` is inside a turn or otherwise not at the boundary and is rejected with a typed `SessionForkError` code. The service also classifies non-live source ids (`SESSION_NOT_FOUND`), stale `Session` object references whose id is live on a different instance (`SESSION_NOT_LIVE`), and duplicate requested child ids (`SESSION_ALREADY_EXISTS`) instead of leaking raw store errors. This is intentionally stricter than `dsh-subagent-fork`, whose completed-prefix clipping remains unchanged because it serves tool-time delegation rather than user/session branching. ## Consequences diff --git a/packages/session-fork/session-fork/README.md b/packages/session-fork/session-fork/README.md index 7f89d299c4..0f8e4f237c 100644 --- a/packages/session-fork/session-fork/README.md +++ b/packages/session-fork/session-fork/README.md @@ -21,7 +21,9 @@ Forking inside a turn is rejected with `SessionForkError` code `OPEN_TURN`. The | 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. | +| `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 diff --git a/packages/session-fork/session-fork/src/index.ts b/packages/session-fork/session-fork/src/index.ts index 0754e86ce1..b7781601ea 100644 --- a/packages/session-fork/session-fork/src/index.ts +++ b/packages/session-fork/session-fork/src/index.ts @@ -46,6 +46,8 @@ export interface ForkSessionOptions { export type SessionForkErrorCode = | 'SESSION_NOT_FOUND' + | 'SESSION_NOT_LIVE' + | 'SESSION_ALREADY_EXISTS' | 'OPEN_TURN' /** Typed error for service-level fork rejections. */ @@ -94,6 +96,9 @@ export class SessionForkService extends Service { */ fork(options: ForkSessionOptions): Session { const snapshot = this.snapshot(options.source) + if (options.sessionId !== undefined && this.ctx.sessions.get(options.sessionId) !== undefined) { + throw new SessionForkError(`session "${options.sessionId}" already exists`, 'SESSION_ALREADY_EXISTS') + } return this.ctx.sessions.create(options.sessionId, { seed: snapshot.seed, meta: snapshot.meta, @@ -108,9 +113,10 @@ export class SessionForkService extends Service { } const live = this.ctx.sessions.get(source.id) - if (live !== source) { + 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 } diff --git a/packages/session-fork/session-fork/tests/session-fork.spec.ts b/packages/session-fork/session-fork/tests/session-fork.spec.ts index c5c931c732..44dcd3c962 100644 --- a/packages/session-fork/session-fork/tests/session-fork.spec.ts +++ b/packages/session-fork/session-fork/tests/session-fork.spec.ts @@ -127,6 +127,15 @@ describe('SessionForkService', () => { .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() + ctx.sessions.create(SessionId('same-id')) + const stale = new Session(SessionId('same-id')) + + expect(() => fork.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 cases: [string, (session: Session) => void][] = [ @@ -184,6 +193,16 @@ describe('SessionForkService', () => { expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }]) }) + it('rejects a child session id that is already live with a typed fork error', async () => { + const { ctx, fork } = await setup() + const source = ctx.sessions.create(SessionId('parent')) + appendClosedTurn(source) + ctx.sessions.create(SessionId('child')) + + expect(() => fork.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()