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

@@ -30,7 +30,6 @@ The default distribution is a composition, not a hierarchy. `packages/core/` is
| `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries |
| `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-surface compaction |
| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers |
| `ctx.sessionFork` | [`session-fork/`](../packages/session-fork/README.md) | live-session fork boundary validation and seed snapshots |
| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs |
## Event Surface
@@ -109,7 +108,7 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the
The session log is the source of truth. `deriveMessages()` projects session events into the `Message[]` sent to the model; raw `assistant/chunk` events stay in the log for replay and UI fidelity. Replay, fork, resume, transcript rendering, telemetry, and persistence all derive from the same event stream.
The low-level fork primitive is `ctx.sessions.create(id, { seed, meta })`. The optional `ctx.sessionFork` service owns live-session fork policy: it validates that a source session is empty or at a turn boundary, snapshots the seed, and creates child metadata without changing the core log.
The low-level fork primitive is `ctx.sessions.create(id, { seed, meta })`. The session store also exposes `ctx.sessions.snapshot(source)` and `ctx.sessions.fork({ source, sessionId? })` for ordinary live-session forks: they validate that a source session is empty or at a turn boundary, snapshot the seed, and create child metadata without changing the core log.
Durability is a plugin concern. Persistence backends buffer synchronous `session/event` notifications and the loop awaits a turn-end checkpoint before moving on. The `SessionPersistence` seam stores `SessionEvent` directly, with metadata in `SessionHeader`; JSONL and SQLite share one contract suite.
@@ -125,7 +124,7 @@ Streaming is a raw chunk protocol (`block-start` through `finish`) with `BlockAs
A swappable capability usually splits into **interface / implementation / consumer**: the interface owns the `ctx` key and vocabulary; an implementation registers a backend; a consumer exposes model-facing behavior through `ctx.tools` or prompt assembly. The bash trio is the reference shape, and the [capability seam graph](capability-seams.md) shows the current package families.
Some seams bend the template deliberately. LLM keeps interface and consumer vocabulary together because adapters are the implementations. Filesystem adds policy as event gates around provider primitives. Web is one service with search and fetch provider registries, so provider swaps do not rename model tools. Session-fork keeps its interface and implementation together because it delegates durable work to the existing session store. Subagents use a named provider registry because multiple delegation backends can coexist; `spawn` starts fresh, `fork` seeds from the parent's completed-turn prefix, and ACP can drive an out-of-process child ([subagent.md](core-data-structures/subagent.md)).
Some seams bend the template deliberately. LLM keeps interface and consumer vocabulary together because adapters are the implementations. Filesystem adds policy as event gates around provider primitives. Web is one service with search and fetch provider registries, so provider swaps do not rename model tools. Subagents use a named provider registry because multiple delegation backends can coexist; `spawn` starts fresh, `fork` seeds from the parent's completed-turn prefix, and ACP can drive an out-of-process child ([subagent.md](core-data-structures/subagent.md)).
### Bundles And Apps
@@ -144,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.sessionFork` to validate the boundary and create a seeded child session |
| Fork a live session | use `ctx.sessions.snapshot()` for reusable seed metadata or `ctx.sessions.fork()` to create a seeded child session |
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).

View File

@@ -126,17 +126,6 @@ Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../co
Source: [`packages/llm/llm/src/index.ts:78`](../../packages/llm/llm/src/index.ts)
## `ctx.sessionFork` — `SessionForkService`
`ctx.sessionFork`: validates live session fork boundaries and creates seeded child sessions using the existing `ctx.sessions.create({ seed })` primitive.
```ts cordis-catalog
snapshot(source: SessionForkSource): SessionForkSeed
fork(options: ForkSessionOptions): Session
```
Source: [`packages/session-fork/session-fork/src/index.ts:65`](../../packages/session-fork/session-fork/src/index.ts)
## `ctx.sessionPersistence` — `SessionPersistence` (abstract seam)
Abstract durable session-persistence service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.sessionPersistence` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
@@ -172,9 +161,11 @@ enter(session: Session): () => void
announce(session: Session): void
get(id: SessionId): Session | undefined
list(): Session[]
snapshot(source: SessionForkSource): SessionForkSeed
fork(options: ForkSessionOptions): Session
```
Source: [`packages/core/session/src/index.ts:327`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:369`](../../packages/core/session/src/index.ts)
## `ctx.subagents` — `SubagentService`

View File

@@ -23,7 +23,6 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` |
| [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
| [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split |
| [session-fork.md](session-fork.md) | the session fork seam: live-session boundary validation, seed snapshot metadata, and child-session creation |
| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider/capability status, `WebError` |
> Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts.

View File

@@ -1,19 +0,0 @@
# Session Fork
The session fork service is an optional capability over the core session store. It does not add new log events or persisted record shapes; it packages the existing seed primitive into a safe service with a turn-boundary policy.
Package: [`@deepseek-ai/dsh-session-fork`](../../packages/session-fork/session-fork) (`ctx.sessionFork`). The decision and rationale are recorded in [the session fork service RFC](../rfc/implemented/feature/2026-06-30-session-fork-service.md).
## Service Shape
`SessionForkService.snapshot(source)` accepts a live `Session` object or live `SessionId`, validates the source log is empty or ends at `turn/end`, then returns the resolved source, a deep-cloned `SessionEvent[]` seed, and child metadata: `parentSession`, `seedLength`, and optional inherited `cwd`.
`SessionForkService.fork({ source, sessionId? })` is a convenience wrapper around `ctx.sessions.create(sessionId, { seed, meta })`. Consumers that create agents can use `snapshot()` directly and pass the returned seed/meta through the agent factory instead of creating a detached session first.
## Boundary Policy
The boundary rule is structural: every `turn/end` reason is forkable, and every non-empty log whose last event is not `turn/end` is rejected. This is intentionally stricter than the subagent fork backend, which clips to the parent's last completed-turn prefix because it is usually invoked from inside the parent's active tool turn.
## Persistence
No persistence method is added. A forked child is just a normal live session with seed events already present at creation time, so existing persistence backends persist the inherited prefix and header metadata through `session/created` and `session/flush`.

View File

@@ -159,6 +159,15 @@ export interface SurfaceNode {
Everything else (`turn/*`, `step/*`) is structural and does not project into a message. Token usage is observed on `assistant/message.usage` (the step that produced it); an operational error's step number is on `turn/end.reason` for `kind: 'error'`.
## Live-session fork helpers
`ctx.sessions.create(id, { seed, meta })` is the low-level replay/fork primitive. For ordinary live-session forks, `SessionStore` adds two policy helpers:
- `snapshot(source)` accepts a live `Session` object or live `SessionId`, validates the source log is empty or ends at `turn/end`, then returns a deep-cloned `SessionEvent[]` seed plus child metadata (`parentSession`, `seedLength`, and inherited `cwd`).
- `fork({ source, sessionId? })` calls `snapshot(source)` and immediately creates the live child via `ctx.sessions.create(sessionId, { seed, meta })`.
The split is intentional: `snapshot()` is the reusable seed/metadata computation for callers that create an agent or defer session creation; `fork()` is the convenience path when a caller only needs a child `Session`. Both reject open-turn sources instead of clipping to an older prefix. `dsh-subagent-fork` keeps its completed-prefix clipping because tool-time delegation usually starts while the parent turn is open; ordinary session branching should not silently drop the parent turn tail.
## What started a turn: `TurnTriggerMap`
```ts type-equiv

View File

@@ -78,9 +78,6 @@ flowchart TD
pkg_app_boot["app-boot"]
pkg_stdio_agent["stdio-agent"]
end
subgraph group_session_fork["packages/session-fork"]
pkg_session_fork["session-fork"]
end
pkg_llm --> pkg_brand
pkg_bash --> pkg_brand
pkg_llm_deepseek --> pkg_llm
@@ -109,7 +106,6 @@ flowchart TD
pkg_session_persistence --> pkg_session
pkg_llm_replay --> pkg_llm
pkg_llm_replay --> pkg_session
pkg_session_fork --> pkg_session
pkg_tools --> pkg_agent
pkg_tools --> pkg_llm
pkg_tools --> pkg_system_prompt
@@ -230,7 +226,6 @@ flowchart TD
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) |
| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) |
| [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-fork`](../packages/session-fork/session-fork) | `session-fork` | [`session`](../packages/core/session) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt) |
| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |

View File

@@ -58,7 +58,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 |
| [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 |
| [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 |
| [Session fork service](implemented/feature/2026-06-30-session-fork-service.md) | 2026-06-30 |
| [SessionStore fork helpers](implemented/feature/2026-06-30-session-store-fork-helpers.md) | 2026-06-30 |
| [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 |
### Simplification

View File

@@ -1,53 +0,0 @@
# RFC: Session fork service
Status: implemented (proposed 2026-06-30, accepted 2026-06-30)
## Context
The event-sourced session log already has the primitive a fork needs: create a new session with a seed event prefix, then derive model history from that seeded log exactly as replay does. That primitive is intentionally low-level. It lives on `dsh-session` as `ctx.sessions.create(id, { seed })`, while durable metadata such as `parentSession` and `seedLength` is stored on the out-of-log `SessionHeader` introduced by [session persistence](../../implemented/architecture/2026-06-14-session-persistence.md). The same mechanics already support in-process subagent fork children and replay routing for forked child logs.
What is missing is a reusable product service for ordinary session forking. Putting that directly on `dsh-session` would make a derived workflow part of the core log API, even though the core session package should stay focused on append-only storage, derived history, and lifecycle events. The harness architecture prefers optional capability plugins over widening the core spine; [event-sourced sessions](../../implemented/architecture/2026-06-11-event-sourced-sessions.md) provide the log semantics, and [capability seams](../../implemented/architecture/2026-06-13-capability-seams.md) provide the extension pattern.
The main semantic hazard is the fork boundary. A session event log is only a valid seed when it is contiguous and balanced. Forking inside an active turn would copy an open `turn/start`, possibly an open `step/start`, and possibly dangling tool calls. That violates the turn-enclosure and provider-transcript invariants, and it creates a misleading child history that appears to have participated in an unfinished parent turn. The existing [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) deliberately solves a different problem: a tool-triggered subagent fork usually happens while the parent turn is open, so `dsh-subagent-fork` clips the seed to the parent's last completed-turn prefix. A general session fork should not silently clip; it should reject attempts made away from a boundary.
## Decision
The shipped design adds an optional product package, `@deepseek-ai/dsh-session-fork`, under `packages/session-fork/session-fork`. It registers `ctx.sessionFork` and depends only on `cordis` plus the `dsh-session` vocabulary/service. No new session event types, persistence methods, ACP methods, agent-loop hooks, or subagent behavior are added in the first cut.
The service exposes two operations:
```ts ignore-check
type SessionForkSource = Session | SessionId
interface SessionForkSeed {
source: Session
seed: SessionEvent[]
meta: {
parentSession: SessionId
seedLength: number
cwd?: string
}
}
interface ForkSessionOptions {
source: SessionForkSource
sessionId?: SessionId
}
class SessionForkService extends Service {
snapshot(source: SessionForkSource): SessionForkSeed
fork(options: ForkSessionOptions): Session
}
```
`snapshot()` is the reusable half. It resolves only live sessions from `ctx.sessions`; v1 does not load unloaded persisted sessions by id. It validates the source is at a turn boundary, deep-clones the source events, and returns the seed plus metadata a caller can pass to a later session or agent creation path. This shape keeps the fork computation reusable for future ACP or agent-facing consumers without coupling this service to `ctx.agents`.
`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. 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
The feature is a small capability seam rather than a change to `dsh-session`: the core log keeps its low-level seed primitive, while `dsh-session-fork` owns policy, error taxonomy, and convenience creation. Persistence continues to work through existing `session/created` and `session/flush` behavior: a forked child starts life with seeded events, so existing backends persist that seed once and preserve `parentSession` / `seedLength` in the header.
The v1 scope deliberately excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. Those can consume `snapshot()` later. If a future ACP method is added, it should advertise the capability only after it has transcript/snapshot coverage; this RFC adds no editor-facing updates, so no ACP snapshot is required now. Fork-child replay remains covered by the existing [seed-boundary testing RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md), while this service gets focused unit tests plus one persistence integration test.

View File

@@ -0,0 +1,59 @@
# RFC: SessionStore fork helpers
Status: implemented
## Problem
The event-sourced session log already has the primitive a fork needs: create a new session with a seed event prefix, then derive model history from that seeded log exactly as replay does. That primitive is intentionally low-level: `ctx.sessions.create(id, { seed, meta })` accepts any valid seed, but ordinary live-session branching needs policy around where the seed may be taken, which metadata is stamped on the child, and how errors are classified.
The semantic hazard is the fork boundary. A session event log is only a valid user-visible fork seed when it is contiguous and balanced. Forking inside an active turn would copy an open `turn/start`, possibly an open `step/start`, and possibly dangling tool calls. That violates the turn-enclosure and provider-transcript invariants, and it creates a misleading child history that appears to have participated in an unfinished parent turn. The existing [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) deliberately solves a different problem: a tool-triggered subagent fork usually happens while the parent turn is open, so `dsh-subagent-fork` clips the seed to the parent's last completed-turn prefix. A general session fork should not silently clip; it should reject attempts made away from a boundary.
## Decision
`dsh-session` owns ordinary live-session fork helpers directly on `ctx.sessions`. There is no separate `dsh-session-fork` package or `ctx.sessionFork` service: the helpers have no independent backend, event vocabulary, lifecycle, or persistence behavior, and all durable work delegates to the existing session store and persistence backends.
The store exposes two operations:
```ts ignore-check
type SessionForkSource = Session | SessionId
interface SessionForkSeed {
source: Session
seed: SessionEvent[]
meta: {
parentSession: SessionId
seedLength: number
cwd?: string
}
}
interface ForkSessionOptions {
source: SessionForkSource
sessionId?: SessionId
}
class SessionStore extends Service {
snapshot(source: SessionForkSource): SessionForkSeed
fork(options: ForkSessionOptions): Session
}
```
`snapshot()` is the reusable half. It resolves only live sessions from `ctx.sessions`; v1 does not load unloaded persisted sessions by id. It validates the source is at a turn boundary, deep-clones the source events, and returns the seed plus metadata a caller can pass to a later session or agent creation path. This keeps the fork computation reusable for future ACP or agent-facing consumers without coupling `dsh-session` to `ctx.agents`.
`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. The helpers also classify 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 lower-level store errors.
## Alternatives considered
**Separate `ctx.sessionFork` service.** This was the first implementation, but review showed it overfit the capability-seam pattern. The code had no swappable backend, no extra event surface, no independent ownership lifecycle, and no durable behavior beyond `ctx.sessions.create({ seed, meta })`. Keeping a separate package would make callers discover and install a second service just to perform policy around a session-store primitive.
**Only expose `fork()`.** A one-function API is simpler for immediate child-session creation, but it forces callers that need a seed for another creation path to create a detached child session just to get the seed. `snapshot()` keeps the seed/metadata computation reusable without importing `ctx.agents` into `dsh-session`; `fork()` remains the simple one-call convenience.
**Silently clip open turns to the last completed boundary.** That is correct for `dsh-subagent-fork`, where delegation often starts while the parent turn is open and the child should inherit only the completed prefix. It is wrong for ordinary user/session branching because it hides that the requested fork point was not actually a valid boundary and silently drops the parent turn tail.
## Consequences
The public surface stays small and discoverable: live session branching is part of `ctx.sessions`, next to `create({ seed })`, rather than a standalone service. Persistence continues to work through existing `session/created` and `session/flush` behavior: a forked child starts life with seeded events, so existing backends persist that seed once and preserve `parentSession` / `seedLength` in the header.
The v1 scope still excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. Those can consume `snapshot()` later. If a future ACP method is added, it should advertise the capability only after it has transcript/snapshot coverage; this RFC adds no editor-facing updates, so no ACP snapshot is required now. Fork-child replay remains covered by the existing [seed-boundary testing RFC](../../implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md), while these helpers get focused `dsh-session` unit tests plus JSONL persistence coverage.