Merge remote-tracking branch 'origin/master' into codex/agent-session-jsonl-location

# Conflicts:
#	docs/module-graph.md
#	docs/rfc/INDEX.md
#	packages/core/agent-core/tests/agent-core.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-13 16:18:44 +08:00
241 files changed
+14508 -4772

No files matched your search

@@ -25,9 +25,9 @@
*/
import { Context } from 'cordis'
import { interruptedTurnClosers, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import { interruptedTurnClosers, SESSION_FORMAT_VERSION, snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import { assertSerializable, seedCoversPrefix } from './index.ts'
import { seedCoversPrefix } from './index.ts'
/**
* A stored session's durable prefix as read back from a backend: its
@@ -186,14 +186,18 @@ export class PersistenceCoordinator<TornMarker = unknown> {
/**
* Register a new session's metadata (lazy: no physical write until the first
* {@link append}). Rejects if the id is already tracked or already persisted.
* @param meta - the immutable header (id, version, cwd, lineage) to record; snapshotted at call time.
* @param meta - the header (id, version, cwd, lineage) to record; materialized
* as a detached lossless-JSON snapshot at call time.
*/
create(meta: SessionHeader): Promise<void> {
// Snapshot the metadata at call time: the op runs later (behind the
// per-session chain) and the snapshot is stored as the lazy state, so keeping
// the caller's object by reference would let a later mutation of `id`/`cwd`
// register under one key but materialize under a different path/header.
const snapshot: SessionHeader = { ...meta }
const snapshot = snapshotJsonValue(meta)
if (snapshot === undefined) {
return Promise.reject(new TypeError('session metadata must be losslessly JSON-serializable'))
}
return this.serialize(snapshot.id, () => this.createCore(snapshot))
}
@@ -212,23 +216,25 @@ export class PersistenceCoordinator<TornMarker = unknown> {
this.states.set(meta.id, { meta, cursor: 0, materialized: false })
}
// `async` so the synchronous validate/clone below reject (not throw) per the
// Promise<void> contract — callers use `await expect(...).rejects`.
// `async` so synchronous materialization failures below reject (not throw) per
// the Promise<void> contract — callers use `await expect(...).rejects`.
/**
* Durably persist a batch of events. Honors the append-only and contiguous-seq
* contracts; rejects non-JSON-serializable `event.data`.
* @param id - the session the batch belongs to.
* @param events - the contiguous batch to persist, in seq order; deep-cloned at call time.
* @param events - the contiguous batch to persist, in seq order; materialized
* as a detached lossless-JSON snapshot at call time.
*/
async append(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
// Validate serializability BEFORE cloning so a bad event surfaces the typed
// error rather than an opaque DataCloneError from structuredClone.
assertSerializable(events)
// Deep-snapshot the batch HERE, before the op waits behind the per-session
// chain: a caller that mutates a live array (e.g. session.events) — or an
// event inside it — before the op runs would otherwise have those changes
// persisted. The clone is taken synchronously (at call time).
const batch = events.map(e => structuredClone(e))
// Validate and deep-snapshot the complete batch HERE, in one traversal,
// before the op waits behind the per-session chain. A check followed by
// structuredClone would reread accessors and could sanitize an exotic value
// into an apparently valid record; the single-pass materializer makes the
// checked value exactly the value persisted.
const batch = snapshotJsonValue(events)
if (batch === undefined) {
throw new TypeError('session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data')
}
return this.serialize(id, () => this.appendCore(id, batch))
}
@@ -338,9 +344,10 @@ export class PersistenceCoordinator<TornMarker = unknown> {
// promise so flush/dispose can await it (onCreated is async).
ctx.on('session/created', (session) => { void this.initFor(session) })
// Snapshot + buffer every event (the live object is mutable; clone so a later
// in-place mutation cannot rewrite a buffered event). Serializability is
// guaranteed at the source (Session.append), so structuredClone is safe.
// 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 = [])
@@ -391,8 +398,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
const existing = this.inits.get(session)
if (existing) return existing
// Snapshot the seed SYNCHRONOUSLY — initFor runs inside the `session/created`
// emit, before any later `append` adds non-seed events. A clone freezes it
// against later mutation of the live event objects.
// emit, before any later append invalidates the public array snapshot. Events
// are already frozen; cloning gives persistence independent ownership.
const seed = session.events.map(e => structuredClone(e))
const p = this.onCreated(session, seed)
// Attach a no-op rejection handler so a failing init does not surface as an
@@ -22,7 +22,7 @@
*/
import { Context, Service } from 'cordis'
import { isJsonValue } from '@deepseek-ai/dsh-session'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
// Re-export the metadata vocabulary so consumers import it from the seam.
@@ -70,16 +70,16 @@ export function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly
}
/**
* Reject non-JSON-serializable event data before a backend serializes a batch.
* Live session appends already enforce this; persistence append paths also
* accept replay/fork batches that may bypass a live session instance.
* @param events - the batch to validate; throws naming the offending event's type and seq.
* Reject a batch that is not wholly losslessly JSON-serializable. Live session
* appends already enforce this; persistence append paths also accept replay or
* direct batches that may bypass a live session instance. Validation uses the
* same one-pass materializer as the coordinator, so getters are read once.
* @param events - the complete event batch to validate.
*/
export function assertSerializable(events: readonly SessionEvent[]): void {
for (const event of events) {
if (!isJsonValue(event.data)) {
throw new Error(`event "${event.type}" carries non-JSON-serializable data (seq ${event.seq})`)
}
const snapshot = snapshotJsonValue(events)
if (snapshot === undefined) {
throw new Error('session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data')
}
}
@@ -102,11 +102,11 @@ export function assertSerializable(events: readonly SessionEvent[]): void {
* {@link load} rejects a parse error or a `seq` gap in the COMMITTED region
* (unloadable); {@link append}'s first event `seq` MUST equal the backend's
* stored next-seq (after `load` has balanced any interrupted turn).
* - **JSON-serializable data.** `SessionEventMap` is merge-extensible and
* `event.data` is typed only as `SessionEventMap[K]`, so {@link append}
* REJECTS non-JSON-serializable data with an error naming the offending
* event type. A backend snapshots (serializes/clones) each event when it
* buffers, since `session.events` hands out the live mutable object.
* - **JSON-serializable events.** `SessionEventMap` is merge-extensible, so
* {@link append} materializes each complete batch through the shared
* lossless-JSON boundary before buffering it. The public `session.events`
* view is immutable, but persistence still snapshots direct/replay callers at
* this independent trust boundary.
* - **Durability.** {@link append} returns only once the batch is durable
* (the file backend fsyncs; a DB commits). {@link create} MAY defer the
* physical write until the first {@link append} (lazy materialization).