Merge remote-tracking branch 'origin/master' into session-query-search

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-10-session-query-service.md
#	.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md
#	.agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.md
#	docs/architecture.md
#	docs/capability-seams.md
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/core-data-structures/persistence.md
#	docs/core-data-structures/session-query.md
#	docs/module-graph.md
#	docs/rfc/INDEX.md
#	packages/README.md
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/hooks/hooks-claude/tests/coverage.spec.ts
#	packages/session-persistence/session-persistence-jsonl/src/index.ts
#	packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
#	packages/session-persistence/session-persistence-sqlite/README.md
#	packages/session-persistence/session-persistence-sqlite/src/index.ts
#	packages/session-persistence/session-persistence-sqlite/src/schema.ts
#	packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts
#	packages/session-persistence/session-persistence/README.md
#	packages/session-persistence/session-persistence/package.json
#	packages/session-query/README.md
#	packages/session-query/session-query/README.md
#	packages/session-query/session-query/package.json
#	packages/session-query/session-query/src/config.ts
#	packages/session-query/session-query/src/index.ts
#	packages/session-query/session-query/src/types.ts
#	pnpm-lock.yaml
#	scripts/gen-doc-graphs.ts
#	scripts/type-equiv.manifest.json
#	tsconfig.host.json
#	tsconfig.json
This commit is contained in:
Hypatia May
2026-07-23 13:56:56 +08:00
2630 changed files with 198302 additions and 30288 deletions
@@ -1,13 +1,14 @@
# @deepseek-ai/dsh-session-persistence
The abstract durable session-persistence seam (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface.
The abstract durable session-persistence seam (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface.
The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage, seed boundary) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here.
The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage, seed boundary, delegation depth) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here.
## Service API (`ctx.sessionPersistence`)
| Method | Contract |
|---|---|
| `locate(meta): SessionLocation \| undefined` | Resolve an absolute per-session artifact target without I/O or materialization. Backends without an independent local artifact return `undefined`. |
| `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
| `append(id, events): Promise<void>` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
| `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. |
@@ -16,14 +17,20 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
## Invariants every backend must honor
- **Append-only; a crashed turn is closed, not truncated.** Committed events (at or below a flushed `turn/end`) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded.
- **Append-only; a crashed turn is closed, not truncated.** Flushed events are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (a risk-classified error `tool/result` per unanswered assistant call, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded.
- **Contiguous seq.** `load` rejects a `seq` gap/parse error in the MIDDLE of the log; `append`'s first `seq` must equal the stored next-seq.
- **JSON-serializable data.** `append` materializes each direct/replay batch through the shared one-pass lossless-JSON boundary. Live `Session` events are already deep-frozen, but the write coordinator still copies each event into a persistence-owned buffer.
- **Durability.** `append` returns only once the batch is durable.
## The write coordinator
`PersistenceCoordinator` owns per-id state, write-behind buffers and serialization, the `session/event` → `session/flush` drain, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its stateful methods. Lightweight snapshot listing remains backend-owned because revisions identify the underlying store; see the [coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
`PersistenceCoordinator` owns per-id state, write-behind buffers and serialization, the `session/event` → `session/flush` drain, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its stateful methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives. Side-effect-free location queries and lightweight snapshot listing remain backend-owned because they describe storage topology and revision identity; see the [coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
The coordinator implements durability at checkpoints but does not select their schedule. Persisted deployments explicitly compose [`dsh-session-checkpoint-policy`](../session-checkpoint-policy) when they want request-, tool-dispatch-, and completed-step recovery boundaries; omitting it leaves the loop's coarser checkpoints intact.
When a live session emits `session/disposed`, the coordinator waits for its initialization, serializes a final buffer drain, then releases every map entry owned by that exact `Session` object. A failed final drain keeps the pending buffer for backend teardown to retry. Backend teardown stops event admission first, awaits all in-flight session retirements and remaining per-id operations, drains any retained buffers, and only then closes the storage handle.
The side-effect-free `locate` query remains backend-owned because it describes storage topology rather than write orchestration.
The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinator and storage):
@@ -37,7 +44,7 @@ The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinato
| `list()` | List all stored metadata. |
| `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. |
The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must also provide trustworthy lightweight snapshot revisions. See [the write-coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must also provide trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
## Testing backends
@@ -45,17 +52,25 @@ Import `runPersistenceContract` from `tests/contract.ts` (the public API, includ
Three backends run these suites: an in-memory reference (in `tests/`), `dsh-session-persistence-jsonl` (append-only file log) and `dsh-session-persistence-sqlite` (`node:sqlite`, each `SessionEvent` one row `(session_id, seq, type, time, data, source_event_seqs, surface_op)`). All passing the same contract + coordinator suite is the proof that the seam is genuinely backend-agnostic — lazy materialization, crash-tail-on-load, and contiguous-seq hold identically over file bytes and over a transactional store.
## Metadata types
## Metadata and location types
Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`, `seedLength?`).
Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `version`, `id`, `createdAt`, `cwd?`, `parentSession?`, `seedLength?`, `delegationDepth?`). `SessionLocation` is `{ readonly kind: string; readonly path: string }`; its path is an absolute backend target, not proof that the artifact exists or contains an unflushed turn.
## Model Experience
### Resumed conversation history
**What the model sees**: This seam adds no prompt or schema. Resume restores stored surface events as message history; stored request headers reconstruct earlier calls, while the new loop composes the current system prompt, tools, and session prefix for its next request. Crash repair inserts exactly `Tool call interrupted by a crash; no result was recorded.` as the error result for each unanswered tool call.
#### What the model sees
**Token effect**: Zero tokens during ordinary persistence. Resume restores retained history cost and pays the current request envelope normally; each repaired call adds the quoted retained error text.
This seam adds no prompt or schema. Resume restores stored surface events as message history; stored request headers reconstruct earlier calls, while the new loop composes the current system prompt, tools, and session prefix for its next request. Crash repair marks an assistant request without a durable call as `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, whose text lets the model retry read-only or idempotent work but directs it to verify side effects or ask the user instead of retrying blindly.
#### Token effect
Zero tokens during ordinary persistence. Resume restores retained history cost and pays the current request envelope normally; each repaired call adds the quoted retained error text.
#### KV Cache effect
Persistence does not mutate live request prefixes. A resumed loop can reuse provider cache only when its reconstructed history, current envelope, and model route match; crash-repair results append without rewriting earlier history.
## Known Limitations and Deferred Work
@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -23,11 +28,14 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
@@ -118,6 +118,25 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio
})
}
/** Reject events from an obsolete v0 vocabulary that this build cannot replay. */
function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): void {
const legacyType: string = 'request/header-delta'
const legacy = events.find(event => event.type === legacyType)
if (legacy !== undefined) {
throw new Error(`session "${id}" contains unsupported legacy request/header-delta event at seq ${legacy.seq}`)
}
const legacyModeType: string = 'mode/set'
const legacyMode = events.find(event => event.type === legacyModeType)
if (legacyMode !== undefined) {
throw new Error(`session "${id}" contains unsupported legacy mode/set event at seq ${legacyMode.seq}`)
}
const fallback = events.find(event => event.type === 'request/header'
&& (event.data as { reason?: string }).reason === 'fallback')
if (fallback !== undefined) {
throw new Error(`session "${id}" contains unsupported legacy request/header reason "fallback" at seq ${fallback.seq}`)
}
}
/**
* Owns the backend-agnostic session write-path orchestration. A backend
* constructs one (`new PersistenceCoordinator(ctx, this)`), implements
@@ -126,7 +145,8 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio
*
* All per-id operations are serialized (a per-id promise chain) so concurrent
* flushes / a flush racing a load never interleave storage writes. The
* constructor installs the write-path listeners and the dispose effect.
* constructor installs the write-path listeners, per-session retirement, and
* the backend dispose effect.
*
* @typeParam TornMarker - the backend's opaque torn-tail repair token.
*/
@@ -146,6 +166,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* observation boundary; callers do not inspect this bookkeeping directly.
*/
private inits = new Map<Session, Promise<void>>()
/** Final drains started by fire-and-forget session disposal notifications. */
private retirements = new Set<Promise<void>>()
constructor(private ctx: Context, private backend: PersistenceBackend<TornMarker>) {
this.installWritePath()
@@ -204,6 +226,11 @@ export class PersistenceCoordinator<TornMarker = unknown> {
}
private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
// Every append route converges here: the public service, live write-behind
// drains, and HMR seed/suffix adoption. Keep vocabulary rejection at that
// shared boundary so a stale JavaScript plugin cannot persist an event that
// this same backend will refuse to load.
assertSupportedEvents(events, id)
if (events.length === 0) return
let state = this.states.get(id)
if (state === undefined) state = await this.adopt(id) // calls loadCore, not load
@@ -238,6 +265,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
if (stored === undefined) throw new Error(`session "${id}" not found`)
const { meta, events, tornMarker } = stored
this.assertVersion(meta)
assertSupportedEvents(events, id)
// Preserve complete interrupted events and synthesize only missing closers.
const closers = interruptedTurnClosers(events)
@@ -267,7 +295,13 @@ export class PersistenceCoordinator<TornMarker = unknown> {
const next = prior.then(op, op)
// Keep the chain alive but swallow this op's rejection for the NEXT waiter
// (the caller still sees the real rejection via `next`).
this.chains.set(id, next.then(() => undefined, () => undefined))
const tail = next.then(() => undefined, () => undefined)
this.chains.set(id, tail)
// Settled tails carry no serialization value. Delete only the exact tail
// installed above: a later operation may already have replaced it.
void tail.then(() => {
if (this.chains.get(id) === tail) this.chains.delete(id)
})
return next
}
@@ -293,27 +327,12 @@ export class PersistenceCoordinator<TornMarker = unknown> {
private installWritePath(): void {
const ctx = this.ctx
// Capture the header on creation; persist a fork's seed once. Record the init
// promise so flush/dispose can await it (onCreated is async).
ctx.on('session/created', (session) => { void this.initFor(session) })
// Session emits an owned frozen event. Keep a persistence-owned copy anyway
// so the write-behind queue owns exactly the record it will flush rather than
// retaining a product-layer record by identity. Serializability is guaranteed
// at the source, so structuredClone is safe.
ctx.on('session/event', (session, event) => {
let buffer = this.buffers.get(session)
if (!buffer) this.buffers.set(session, buffer = [])
buffer.push(structuredClone(event))
})
// Drain to the backend at the durability checkpoint.
ctx.on('session/flush', session => this.flush(session))
// Dispose must reach quiescence: await every init + final drain BEFORE
// returning, then close the backend's own resources (AFTER the drain), so no
// write lands after teardown and a close failure never MASKS a drain error.
// Register the disposer BEFORE the listeners. Cordis tears effects down in
// reverse registration order, so event admission closes before this final
// drain reaches quiescence and closes the backend.
ctx.effect(() => async () => {
await this.awaitRetirements()
let disposeError: unknown
try {
const errors = [
@@ -341,11 +360,63 @@ export class PersistenceCoordinator<TornMarker = unknown> {
}
}, `${this.backend.name} write path`)
// Capture the header on creation; persist a fork's seed once. Record the init
// promise so flush/dispose can await it (onCreated is async).
ctx.on('session/created', (session) => { void this.initFor(session) })
// Session emits an owned frozen event. Keep a persistence-owned copy anyway
// so the write-behind queue owns exactly the record it will flush rather than
// retaining a product-layer record by identity. Serializability is guaranteed
// at the source, so structuredClone is safe.
ctx.on('session/event', (session, event) => {
let buffer = this.buffers.get(session)
if (!buffer) this.buffers.set(session, buffer = [])
buffer.push(structuredClone(event))
})
// Drain to the backend at the durability checkpoint.
ctx.on('session/flush', session => this.flush(session))
// Session disposal is observe-only, so the coordinator observes the
// detached task itself and backend teardown awaits quiescence.
ctx.on('session/disposed', (session) => { this.retire(session) })
// HMR: a hot reload does not replay session/created, so seed existing live
// sessions (mirrors dsh-invariants).
for (const session of ctx.sessions.list()) void this.initFor(session)
}
/** Start, observe, and track one disposed session's final drain. */
private retire(session: Session): void {
const task = this.retireCore(session)
this.retirements.add(task)
const settled = (): void => { this.retirements.delete(task) }
void task.then(settled, (error: unknown) => {
settled()
this.ctx.logger.warn(`${this.backend.name}: session "${session.id}" retirement failed: ${String(error)}`)
})
}
/** Drain and release state owned by one exact disposed Session lifecycle. */
private async retireCore(session: Session): Promise<void> {
await this.inits.get(session)
const id = session.header.id
await this.serialize(id, async () => {
await this.drain(session)
this.buffers.delete(session)
this.inits.delete(session)
if (this.states.get(id)?.owner === session) this.states.delete(id)
})
}
/** Await every retirement admitted before listener teardown. */
private async awaitRetirements(): Promise<void> {
while (this.retirements.size > 0) {
await Promise.allSettled([...this.retirements])
}
}
/** Start (once) the async init for a session and remember its promise. */
private initFor(session: Session): Promise<void> {
const existing = this.inits.get(session)
@@ -463,6 +534,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
private async adoptLivePrefix(session: Session, seed: readonly SessionEvent[], stored: StoredPrefix<TornMarker>): Promise<void> {
const { meta, events, tornMarker } = stored
this.assertVersion(meta)
assertSupportedEvents(events, session.header.id)
if (!seedCoversPrefix(seed, events)) {
throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`)
}
@@ -31,6 +31,18 @@ declare module 'cordis' {
}
}
/**
* A backend-resolved, per-session local artifact location. The path is an
* absolute target path and can name an artifact that has not materialized yet.
* Consumers must treat it as a location hint, never as an authorization token.
*/
export interface SessionLocation {
/** Backend-specific artifact kind, for example `jsonl`. */
readonly kind: string
/** Absolute path to this session's backend-owned artifact. */
readonly path: string
}
/**
* Durable append-only session storage. Implementations preserve contiguous,
* losslessly JSON-serializable events; {@link append} resolves only after
@@ -42,6 +54,15 @@ export abstract class SessionPersistence extends Service {
super(ctx, 'sessionPersistence')
}
/**
* Resolve this backend's independent local artifact for a session without
* reading, creating, flushing, or otherwise materializing it. Backends such
* as SQLite that do not own one artifact per session return `undefined`.
* @param meta - the immutable session header whose artifact is requested.
* @returns the backend-specific absolute location, when one exists.
*/
abstract locate(meta: SessionHeader): SessionLocation | undefined
/**
* Register a new session's metadata. A backend MAY defer the physical write
* until the first {@link append} (lazy materialization), in which case a
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-persistence`.
* @module @deepseek-ai/dsh-session-persistence/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence'
/** Cordis companion plugin name. */
export const name = 'session-persistence-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests;
* this package exposes no continuously observable in-process relation.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -9,8 +9,8 @@
*/
import { describe, expect, it } from 'vitest'
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
import { SESSION_FORMAT_VERSION, Session, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { SessionPersistence } from '../src/index.ts'
@@ -36,7 +36,7 @@ export function oneTurnLog(): SessionEvent[] {
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: 2, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 3, time: 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'hello' }] }, surfaceOp: 'append' },
{ type: 'assistant/message', seq: 3, time: 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'hello' }], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append' },
{ type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
]
@@ -127,7 +127,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
}
})
it('crash recovery: an interrupted tool call gets a synthetic error result so resume is a valid transcript', async () => {
it('crash recovery: an unstarted assistant tool request gets a retryable synthetic result', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('interrupted-toolcall')
@@ -141,7 +141,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 8, time: 9, data: { turn: 2, step: 1, content: [
{ type: 'tool-call', id: CallId('call-x'), name: 'bash', arguments: '{}' },
] } },
], provenance: { provider: 'mock', model: 'mock' } } },
])
const loaded = await persistence.load(m.id)
@@ -154,7 +154,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
])
const synthetic = loaded.events.find(e => e.type === 'tool/result')
expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({
callId: CallId('call-x'), isError: true, error: { code: 'interrupted' },
callId: CallId('call-x'), isError: true, error: { code: TOOL_NOT_STARTED },
})
// The synthetic result carries the SAME callId as the orphaned tool-call,
// so deriveMessages() pairs them — no provider-invalid dangling call.
@@ -167,6 +167,40 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
}
})
it('crash recovery: a recorded tool call with no result tells the model to assess retry risk', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('unknown-tool-outcome')
await persistence.create(m)
await persistence.append(m.id, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 3, data: { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('call-risk'), name: 'write', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append' },
{ type: 'tool/call', seq: 3, time: 4, data: { turn: 1, step: 1, callId: CallId('call-risk'), name: 'write', arguments: '{}' } },
])
const loaded = await persistence.load(m.id)
const synthetic = loaded.events.find(e => e.type === 'tool/result')
expect(synthetic?.type === 'tool/result' && synthetic.data.error).toEqual({
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
})
if (synthetic?.type !== 'tool/result' || synthetic.data.content[0]?.type !== 'text') {
throw new Error('expected a text tool result')
}
expect(synthetic.data.content[0].text).toContain('retry only if the operation is read-only or idempotent')
expect(synthetic.data.content[0].text).toContain('if it may have side effects, first verify external state or ask the user')
const resumed = new Session(m.id, loaded.events, loaded.meta)
const resumedResult = resumed.deriveMessages().find(message => message.content.some(block => block.type === 'tool-result'))
expect(resumedResult?.content[0]).toMatchObject({
type: 'tool-result', toolCallId: CallId('call-risk'), isError: true,
})
} finally {
await dispose()
}
})
it('list() excludes a created-but-never-appended (zero-event) session', async () => {
const { persistence, dispose } = await make()
try {
@@ -9,8 +9,9 @@
* @module @deepseek-ai/dsh-session-persistence/tests/coordinator-contract
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { meta, oneTurnLog, appendLog } from './contract.ts'
@@ -76,7 +77,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
try {
const session = ctx.sessions.create(SessionId('live'), { meta: { cwd: WORK } })
send(session, oneTurnLog())
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('live'))
expect(loaded.events).toHaveLength(6)
@@ -96,7 +97,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
try {
const session = ctx.sessions.create(SessionId('forked-child'), { meta: { cwd: WORK, seedLength: 3 } })
send(session, oneTurnLog())
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('forked-child'))
expect(loaded.meta.seedLength).toBe(3)
@@ -106,22 +107,43 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('round-trips the delegation depth through persistence', async () => {
// A subagent child's recursion budget lives in its header; a reload that
// dropped it would reset the child to top-level and un-bound maxDepth
// (JSONL stores it in the header line; SQLite uses `delegation_depth`).
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
const session = ctx.sessions.create(SessionId('delegated-child'), {
meta: { cwd: WORK, parentSession: SessionId('root'), delegationDepth: 2 },
})
send(session, oneTurnLog())
await ctx.parallel('session/flush', session)
const loaded = await ctx.sessionPersistence.load(SessionId('delegated-child'))
expect(loaded.meta.delegationDepth).toBe(2)
} finally {
await fiber.dispose()
await fix.cleanup()
}
})
it('source-frozen events cannot be mutated after buffering and persist unchanged', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
const session = ctx.sessions.create(SessionId('mutate'), { meta: { cwd: WORK } })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
expect(() => {
;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
}).toThrow(TypeError)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('mutate'))
const first = loaded.events[0]
expect(first?.type === 'user/message' && (first.data.content[0] as { text: string }).text).toBe('original')
const message = loaded.events.find(event => event.type === 'user/message')
expect(message?.type === 'user/message' && (message.data.content[0] as { text: string }).text).toBe('original')
} finally {
await fiber.dispose()
await fix.cleanup()
@@ -166,7 +188,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const loaded = await ctx.sessionPersistence.load(SessionId('forked'))
expect(loaded.events).toEqual(seed)
// A flush with no NEW events must not double-write.
await ctx.parallel('session/flush', forked)
await ctx.sessions.flush(forked)
const reloaded = await ctx.sessionPersistence.load(SessionId('forked'))
expect(reloaded.events).toEqual(seed)
} finally {
@@ -182,7 +204,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
try {
const s1 = first.ctx.sessions.create(SessionId('resumed'), { meta: { cwd: WORK } })
send(s1, oneTurnLog())
await first.ctx.parallel('session/flush', s1)
await first.ctx.sessions.flush(s1)
} finally {
await first.fiber.dispose()
}
@@ -194,7 +216,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await second.ctx.sessions.flush(s2) // let onCreated adopt
s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
await second.ctx.parallel('session/flush', s2)
await second.ctx.sessions.flush(s2)
const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
@@ -212,13 +234,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await ctx.plugin(SessionStore)
// A session exists BEFORE the persistence plugin is applied.
const session = ctx.sessions.create(SessionId('pre-existing'), { meta: { cwd: WORK } })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const fiber = await fix.mount(ctx)
try {
// The plugin seeded it on apply; a subsequent flush persists its events.
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('pre-existing'))
expect(loaded.events.length).toBeGreaterThanOrEqual(2)
} finally {
@@ -233,6 +256,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await ctx.plugin(SessionStore)
const fiber = await fix.mount(ctx)
const session = await liveSessionInFiber(ctx, 'drain', WORK)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// No explicit flush — dispose must drain.
@@ -261,7 +285,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
// Hot-reload: dispose instance 1, mount instance 2 over the same storage while the
// session stays live. The new instance has no coordinator state but must adopt the
@@ -271,7 +295,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'again' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
await expect(ctx.parallel('session/flush', session)).resolves.not.toThrow()
await expect(ctx.sessions.flush(session)).resolves.not.toThrow()
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt'))
expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2)
@@ -291,7 +315,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const backend1 = await fix.mount(ctx)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
// Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT
// flushing turn 2: it is now ONLY in the live session's events; the new
@@ -303,7 +327,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// Instance 2 adopts the stored prefix (turn 1) and MUST also persist the
// live suffix (turn 2) carried in the session's events.
await fix.mount(ctx)
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-suffix'))
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3])
expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2)
@@ -322,7 +346,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const first = await fix.mount(ctx)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
// Crash-tail a torn fragment past the (open) committed turn, then reload.
await first.dispose()
@@ -332,7 +356,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// end. Adoption must truncate the torn tail but NOT synthesize closers.
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open'))
expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
@@ -352,7 +376,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
try {
const s1 = first.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } })
send(s1, oneTurnLog())
await first.ctx.parallel('session/flush', s1)
await first.ctx.sessions.flush(s1)
} finally {
await first.fiber.dispose()
}
@@ -392,7 +416,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined()
reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', reuse)
await ctx.sessions.flush(reuse)
const loaded = await ctx.sessionPersistence.load(SessionId('abandoned'))
expect(loaded.events.map(e => e.seq)).toEqual([0, 1])
} finally {
@@ -401,7 +425,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('does NOT reclaim an id whose abandoned owner still has buffered (unflushed) events', async () => {
it('session disposal drains buffered events before retiring ownership', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
@@ -413,13 +437,20 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// Append a turn but do NOT flush — events sit in the write-behind buffer.
first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await firstFiber.dispose() // disposed before flush; not materialized, buffer pending
await firstFiber.dispose()
// Disposal is an observe-only notification. Poll storage rather than
// assuming the owning fiber awaits the coordinator's detached drain.
await vi.waitFor(async () => {
expect((await ctx.sessionPersistence.list()).map(meta => meta.id)).toContain(SessionId('buffered'))
})
expect((await ctx.sessionPersistence.load(SessionId('buffered'))).events.map(event => event.seq)).toEqual([0, 1])
let reuse!: Session
await ctx.plugin(Object.assign((inner: Context) => {
reuse = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } })
}, { inject: ['sessions'] }))
await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/already bound to a different live session/)
await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/persisted log|id collision/)
} finally {
await fiber.dispose()
await fix.cleanup()
@@ -431,14 +462,15 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
const { ctx, fiber } = await freshCtx(fix)
try {
const session = ctx.sessions.create(SessionId('idem'), { meta: { cwd: WORK } })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
// Re-emit session/created for the SAME live session (idempotent initFor).
ctx.emit('session/created', session)
await ctx.parallel('session/flush', session)
ctx.emit(scopeTarget(session, undefined), 'session/created', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('idem'))
expect(loaded.events).toHaveLength(2) // not doubled
expect(loaded.events).toHaveLength(3) // not doubled
} finally {
await fiber.dispose()
await fix.cleanup()
@@ -683,11 +715,12 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// async onCreated init has necessarily set state (exercises the
// state-undefined cursor path).
const session = ctx.sessions.create(SessionId('flush-nostate'), { meta: { cwd: WORK } })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(SessionId('flush-nostate'))
expect(loaded.events).toHaveLength(2)
expect(loaded.events).toHaveLength(3)
} finally {
await fiber.dispose()
await fix.cleanup()
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import {
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix,
@@ -12,9 +12,48 @@ import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-c
/** The durable store shape: materialized sessions only (no lazy entries). */
type MemoryStore = Map<string, { meta: SessionHeader; events: SessionEvent[] }>
/** An obsolete event fixture that emulates an untyped pre-change producer. */
function legacyHeaderDelta(seq = 0): SessionEvent {
return {
type: 'request/header-delta',
seq,
time: 1,
data: { config: { model: 'legacy' } },
} as unknown as SessionEvent
}
/** An unsupported named-mode fixture emulating an untyped producer. */
function legacyModeSet(seq = 0): SessionEvent {
return {
type: 'mode/set',
seq,
time: 1,
data: { mode: 'plan' },
} as unknown as SessionEvent
}
/** An obsolete full-header reason fixture from the removed delta codec. */
function legacyFallbackHeader(seq = 0): SessionEvent {
return {
type: 'request/header',
seq,
time: 1,
data: { header: { config: { model: 'legacy' } }, reason: 'fallback' },
} as unknown as SessionEvent
}
/** Optional plugin config: an EXTERNAL store shared across backend instances. */
interface MemoryConfig { store?: MemoryStore }
/** Test-only view of the coordinator containers whose retirement is the contract under test. */
interface CoordinatorInternals {
states: Map<unknown, unknown>
buffers: Map<unknown, unknown>
chains: Map<unknown, unknown>
inits: Map<unknown, unknown>
retirements: Set<Promise<void>>
}
/**
* Reference {@link PersistenceCoordinator} vehicle and abstract-service coverage, backed by a
* dependency-free map with atomic writes and no torn-tail marker. Supplying the map lets multiple
@@ -41,6 +80,10 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
// --- service surface (delegated to the coordinator) ---
locate(_meta: SessionHeader): undefined {
return undefined
}
create(m: SessionHeader): Promise<void> {
return this.coordinator.create(m)
}
@@ -104,6 +147,49 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
}
}
/** Controllable storage primitive for serialization and retirement failure tests. */
class ControlledBackend implements PersistenceBackend<never> {
readonly name = 'session-persistence-controlled'
readonly store: MemoryStore = new Map()
readonly lifecycle: string[] = []
appendAttempts = 0
loadAttempts = 0
beforeAppend?: (attempt: number) => Promise<void>
beforeLoadStored?: (attempt: number) => Promise<void>
async loadStored(id: SessionId): Promise<StoredPrefix<never> | undefined> {
await this.beforeLoadStored?.(++this.loadAttempts)
const entry = this.store.get(id)
if (entry === undefined) return undefined
return { meta: structuredClone(entry.meta), events: structuredClone(entry.events) }
}
loadLive(id: SessionId, _cwd: string | undefined): Promise<StoredPrefix<never> | undefined> {
return this.loadStored(id)
}
async appendBatch(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise<void> {
const attempt = ++this.appendAttempts
await this.beforeAppend?.(attempt)
const entry = this.store.get(m.id)
if (entry === undefined) {
this.store.set(m.id, { meta: structuredClone(m), events: structuredClone(events) as SessionEvent[] })
} else {
entry.events.push(...structuredClone(events) as SessionEvent[])
}
}
async commitRepair(_m: SessionHeader, _tornMarker: undefined, _closers: readonly SessionEvent[]): Promise<void> {}
async list(): Promise<SessionHeader[]> {
return [...this.store.values()].map(entry => structuredClone(entry.meta))
}
async close(): Promise<void> {
this.lifecycle.push('close')
}
}
// Run the shared contract against the in-memory backend.
runPersistenceContract('memory', async () => {
const ctx = new Context()
@@ -125,6 +211,230 @@ runCoordinatorContract('memory', async (): Promise<CoordinatorFixture> => {
}
})
describe('PersistenceCoordinator retirement', () => {
it('a retiring unmaterialized owner without buffered events releases its id', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
let coordinator!: PersistenceCoordinator<never>
const backendFiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
const loadGate = Promise.withResolvers<boolean>()
try {
const id = SessionId('retiring-lazy-owner')
let first!: Session
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
first = inner.sessions.create(id)
}, { inject: ['sessions'] }))
await ctx.sessions.flush(first)
const baselineLoads = backend.loadAttempts
backend.beforeLoadStored = async () => { await loadGate.promise }
const blockingLoad = coordinator.load(id)
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 1) })
await firstFiber.dispose()
let reuse!: Session
await ctx.plugin(Object.assign((inner: Context) => {
reuse = inner.sessions.create(id)
}, { inject: ['sessions'] }))
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 2) })
loadGate.resolve(true)
await expect(blockingLoad).rejects.toThrow(/not found/)
await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined()
} finally {
loadGate.resolve(true)
await backendFiber.dispose()
await ctx.fiber.dispose()
}
})
it('a retiring owner with buffered events still rejects same-id reuse', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
let coordinator!: PersistenceCoordinator<never>
const backendFiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
const loadGate = Promise.withResolvers<boolean>()
try {
const id = SessionId('retiring-buffered-owner')
let first!: Session
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
first = inner.sessions.create(id)
}, { inject: ['sessions'] }))
await ctx.sessions.flush(first)
first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const baselineLoads = backend.loadAttempts
backend.beforeLoadStored = async () => { await loadGate.promise }
const blockingLoad = coordinator.load(id)
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 1) })
await firstFiber.dispose()
let reuse!: Session
await ctx.plugin(Object.assign((inner: Context) => {
reuse = inner.sessions.create(id)
}, { inject: ['sessions'] }))
await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/bound to a different live session/)
loadGate.resolve(true)
await expect(blockingLoad).rejects.toThrow(/not found/)
await vi.waitFor(() => {
expect(backend.store.get(id)?.events.map(event => event.seq)).toEqual([0, 1])
})
} finally {
loadGate.resolve(true)
await backendFiber.dispose()
await ctx.fiber.dispose()
}
})
it('a settled chain tail cannot delete a newer operation for the same id', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
let coordinator!: PersistenceCoordinator<never>
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
const internals = coordinator as unknown as CoordinatorInternals
const first = Promise.withResolvers<boolean>()
const second = Promise.withResolvers<boolean>()
backend.beforeAppend = async (attempt) => {
if (attempt === 1) await first.promise
if (attempt === 2) await second.promise
}
try {
const id = SessionId('chain-tail')
await coordinator.create(meta(id))
const firstAppend = coordinator.append(id, [{
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
}])
const secondAppend = coordinator.append(id, [{
type: 'turn/end',
seq: 1,
time: 2,
data: { turn: 1, reason: { kind: 'completed' } },
}])
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
first.resolve(true)
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(2) })
expect(internals.chains.size).toBe(1)
second.resolve(true)
await Promise.all([firstAppend, secondAppend])
await vi.waitFor(() => { expect(internals.chains.size).toBe(0) })
expect(backend.store.get(id)?.events.map(event => event.seq)).toEqual([0, 1])
} finally {
first.resolve(true)
second.resolve(true)
await fiber.dispose()
await ctx.fiber.dispose()
}
})
it('backend teardown retries a failed session retirement before close', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
let coordinator!: PersistenceCoordinator<never>
const backendFiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
const internals = coordinator as unknown as CoordinatorInternals
backend.beforeAppend = async (attempt) => {
if (attempt === 1) {
backend.lifecycle.push('append-failed')
throw new Error('transient append failure')
}
backend.lifecycle.push('append-committed')
}
try {
let session!: Session
const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create(SessionId('retry-retirement'))
}, { inject: ['sessions'] }))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await sessionFiber.dispose()
await vi.waitFor(() => {
expect(backend.appendAttempts).toBe(1)
expect(internals.retirements.size).toBe(0)
})
expect([...internals.buffers.values()]).toEqual([expect.arrayContaining([
expect.objectContaining({ seq: 0 }),
expect.objectContaining({ seq: 1 }),
])])
await backendFiber.dispose()
expect(backend.store.get(SessionId('retry-retirement'))?.events.map(event => event.seq)).toEqual([0, 1])
expect(backend.lifecycle).toEqual(['append-failed', 'append-committed', 'close'])
} finally {
await backendFiber.dispose()
await ctx.fiber.dispose()
}
})
it('backend teardown waits for an in-flight session retirement before close', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
let coordinator!: PersistenceCoordinator<never>
const backendFiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
const internals = coordinator as unknown as CoordinatorInternals
const appendGate = Promise.withResolvers<boolean>()
backend.beforeAppend = async () => {
backend.lifecycle.push('append-started')
await appendGate.promise
backend.lifecycle.push('append-committed')
}
try {
let session!: Session
const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create(SessionId('inflight-retirement'))
}, { inject: ['sessions'] }))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await sessionFiber.dispose()
await vi.waitFor(() => {
expect(backend.appendAttempts).toBe(1)
expect(internals.retirements.size).toBe(1)
})
let disposed = false
const teardown = backendFiber.dispose().then(() => { disposed = true })
await Promise.resolve()
expect(disposed).toBe(false)
expect(backend.lifecycle).toEqual(['append-started'])
appendGate.resolve(true)
await teardown
expect(backend.store.get(SessionId('inflight-retirement'))?.events.map(event => event.seq)).toEqual([0, 1])
expect(backend.lifecycle).toEqual(['append-started', 'append-committed', 'close'])
} finally {
appendGate.resolve(true)
await backendFiber.dispose()
await ctx.fiber.dispose()
}
})
})
describe('SessionPersistence service registration', () => {
it('registers as ctx.sessionPersistence and is removed on fiber dispose (HMR safety)', async () => {
const ctx = new Context()
@@ -158,4 +468,108 @@ describe('SessionPersistence service registration', () => {
.rejects.toThrow('session metadata must be losslessly JSON-serializable')
await fiber.dispose()
})
it('rejects a legacy header delta from a pre-change live producer', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(MemoryPersistence)
const session = ctx.sessions.create(SessionId('legacy-live'), { meta: { cwd: '/legacy' } })
// Model the runtime shape available to JavaScript or a hot-loaded plugin
// compiled against the obsolete event vocabulary.
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
expect(() => appendLegacy('request/header-delta', { config: { model: 'legacy' } }))
.toThrow(/unsupported legacy request\/header-delta format/)
expect(session.events).toHaveLength(0)
await fiber.dispose()
})
it('rejects a legacy fallback header buffered by a pre-change live producer', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(MemoryPersistence)
const session = ctx.sessions.create(SessionId('legacy-fallback-live'), { meta: { cwd: '/legacy' } })
const appendLegacy = session.append.bind(session) as (type: string, data: unknown) => SessionEvent
expect(() => appendLegacy('request/header', legacyFallbackHeader().data))
.toThrow('unsupported legacy request/header reason "fallback"')
expect(session.events).toHaveLength(0)
await fiber.dispose()
})
it('rejects a legacy stored prefix during live HMR adoption', async () => {
const id = SessionId('legacy-hmr')
const m = meta(id, '/legacy')
const legacy = legacyHeaderDelta()
const store: MemoryStore = new Map([[id, { meta: m, events: [legacy] }]])
const ctx = new Context()
await ctx.plugin(SessionStore)
// A current live session cannot carry the obsolete event in its seed, but
// HMR still has to identify the persisted prefix as unsupported rather than
// treating it as an ordinary live-prefix collision.
const session = ctx.sessions.create(id, { meta: { cwd: '/legacy' } })
const fiber = await ctx.plugin(MemoryPersistence, { store })
await expect(ctx.sessions.flush(session))
.rejects.toThrow(/unsupported legacy request\/header-delta event at seq 0/)
await Promise.allSettled([fiber.dispose()])
})
it('rejects a stored legacy fallback header during load', async () => {
const id = SessionId('legacy-fallback-load')
const m = meta(id, '/legacy')
const store: MemoryStore = new Map([[id, { meta: m, events: [legacyFallbackHeader()] }]])
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(MemoryPersistence, { store })
await expect(ctx.sessionPersistence.load(id))
.rejects.toThrow('unsupported legacy request/header reason "fallback" at seq 0')
await fiber.dispose()
})
it('rejects a stored legacy named-mode event during load', async () => {
const id = SessionId('legacy-mode-load')
const m = meta(id, '/legacy')
const store: MemoryStore = new Map([[id, { meta: m, events: [legacyModeSet()] }]])
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(MemoryPersistence, { store })
await expect(ctx.sessionPersistence.load(id))
.rejects.toThrow('unsupported legacy mode/set event at seq 0')
await fiber.dispose()
})
it('retires all coordinator bookkeeping for disposed sessions', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(MemoryPersistence)
const { coordinator } = ctx.sessionPersistence as unknown as { coordinator: CoordinatorInternals }
try {
for (let index = 0; index < 3; index += 1) {
let session!: Session
const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create(SessionId(`disposed-${index}`))
}, { inject: ['sessions'] }))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.sessions.flush(session)
await sessionFiber.dispose()
}
await vi.waitFor(() => {
expect(ctx.sessions.list()).toHaveLength(0)
expect({
states: coordinator.states.size,
buffers: coordinator.buffers.size,
chains: coordinator.chains.size,
inits: coordinator.inits.size,
retirements: coordinator.retirements.size,
}).toEqual({ states: 0, buffers: 0, chains: 0, inits: 0, retirements: 0 })
})
} finally {
await fiber.dispose()
}
})
})
@@ -19,6 +19,9 @@
},
{
"path": "../../core/session"
},
{
"path": "../../support/invariants"
}
]
}