feat: add session fork service

This commit is contained in:
Hypatia May
2026-06-30 12:53:46 +08:00
parent 3f85f522ea
commit da94bfd37c
18 changed files with 542 additions and 2 deletions

View File

@@ -54,6 +54,7 @@ Dependency rule: **extension** plugins depend on interface packages, never on `d
| `ctx.agentLoop` | `AgentLoop` | dsh-agent-loop | creates `ReactLoopAgent`s and drives their loops |
| `ctx.bash` | `BashExecutor` (abstract) | dsh-bash | bash execution seam: foreground runs + background tasks |
| `ctx.compact` | `CompactService` (abstract) | dsh-compact | compaction seam: decide when history is too large, summarize an older range into a single surface node |
| `ctx.sessionFork` | `SessionForkService` | dsh-session-fork | live-session fork seam: validate turn-boundary forks, snapshot seed events, create forked child sessions |
All registrations (`registerAdapter`, `section`, `tools`, `register`, …) go through `ctx.effect()` and return disposers, so plugin hot-reload (vendored HMR) and fiber disposal clean up automatically.
@@ -88,7 +89,7 @@ A `Session` is an append-only log of typed `SessionEvent`s — the single source
- `tool/result` → user message carrying a `tool-result` block
- `context/message`, `steering/message` → user-role messages wrapped in a tagged envelope (`<context source="…">…</context>`) at their chronological position — the "system-reminder" pattern; models distinguish them from real user prompts by the envelope. **TODO(review)**: the real adapters now exist (the original precondition); the envelope still wants a deliberate review against live model behavior (`TODO(review)` in dsh-session).
Replay/fork = `ctx.sessions.create(id, { seed: seedEvents })`. Trace/telemetry = listen to `session/event`.
Replay/fork = `ctx.sessions.create(id, { seed: seedEvents })`; user-facing live-session fork policy lives in the optional `ctx.sessionFork` service, which rejects non-boundary forks instead of changing the core store. Trace/telemetry = listen to `session/event`.
**Durability seam**: `session/event` is a synchronous notification; persistence plugins buffer (write-behind) and drain at the awaited `session/flush` checkpoint the loop fires at every turn end. The durable backend is a real **capability seam**: the abstract `SessionPersistence` service (`dsh-session-persistence`, `ctx.sessionPersistence`) defines create/append/load/list over the existing `SessionEvent` (no parallel persisted type), and `dsh-session-persistence-jsonl` is the first implementation — an append-only JSONL log per session with crash-safe atomic writes, crash recovery that PRESERVES an interrupted turn (closing it with a synthetic `turn/end {interrupted}` rather than truncating — a turn can be huge), and a read/replay path. Session metadata (format version, cwd, lineage) travels separately as `SessionHeader`, attached to a `Session` via `session.header`. Resuming a persisted session into a live agent is `ctx.agents.resume({ resumeSessionId })`. A second backend, `dsh-session-persistence-sqlite` (`node:sqlite`, one row per `SessionEvent` — the row shape `(session_id, seq, type, time, data)` maps 1:1 onto it), passes the same `runPersistenceContract` suite, proving the seam is genuinely backend-agnostic.
@@ -194,6 +195,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl
| Dynamic workflow | orchestrator plugin on `agent/turn-end` / `agent/step-end` driving `send`/`steer` (+ sub-agents later) |
| Queued + steering messages | core `Agent.send()` / `Agent.steer()` |
| Context compaction (auto + manual) | the `ctx.compact` seam ([dsh-compact](../packages/compact/compact)): a backend summarizes an older surface range into a single `user/message` `replace` op, bracketed by log-only `compact/*` events; auto = check token pressure at turn boundaries, manual = a `/compact` tool. See the [compaction capability-seam RFC](rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) |
| Session fork | the `ctx.sessionFork` seam ([dsh-session-fork](../packages/session-fork/session-fork)): validate the source is at a turn boundary, snapshot its seed, and create a child session with `parentSession`/`seedLength` metadata. |
| System prompt configurability | `ctx.systemPrompt.section()` with ordering |
| AGENTS.md (root) | a section provider reading the file |
| AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener |
@@ -223,4 +225,4 @@ Tracked here deliberately — each is designed-for but not implemented:
- **Sub-agent spawn/fork semantics** (seam: `AgentLoop.create()`); inter-agent channels beyond `send`/`steer`/events.
- **Compaction implementation** (auto thresholds, summarization prompts) on the `agent/request` seam, with its session-event types added by declaration merging.
- **Parallel tool execution** (concurrency-safety hints on ToolDefinition).
- **Session branching/tree** (pi-style entry tree) if needed beyond seed-based forking.
- **Session branching/tree** (pi-style entry tree) if needed beyond the current seed-based `ctx.sessionFork` service.

View File

@@ -393,6 +393,17 @@ Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../co
Source: [`packages/llm/llm/src/index.ts:69`](../../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:63`](../../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).

View File

@@ -22,6 +22,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s |
| [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 |
> 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

@@ -0,0 +1,19 @@
# 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

@@ -22,6 +22,7 @@ graph TD
compact --> session
llm-replay --> llm
llm-replay --> session
session-fork --> session
session-persistence --> session
invariants --> agent
invariants --> llm
@@ -108,6 +109,7 @@ graph TD
| `agent` | `brand`, `llm`, `session` |
| `compact` | `llm`, `session` |
| `llm-replay` | `llm`, `session` |
| `session-fork` | `session` |
| `session-persistence` | `session` |
| `invariants` | `agent`, `llm`, `session` |
| `session-persistence-jsonl` | `session`, `session-persistence` |

View File

@@ -86,6 +86,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
| [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 |
| [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 |
| [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 |
| [Session fork service](implemented/feature/2026-06-30-session-fork-service.md) | 2026-06-30 |
### Simplification

View File

@@ -0,0 +1,53 @@
# 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. 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.