docs: rebalance prose cleanup and add trimming skill

This commit is contained in:
Tianyi Cui
2026-07-13 23:27:00 +08:00
parent fcdc318dda
commit 148046b9c8
392 changed files with 2801 additions and 1754 deletions

View File

@@ -75,6 +75,9 @@ function isHeaderLine(value: unknown): value is HeaderLine {
* Encode an arbitrary string as a single safe path segment, injectively over ALL JS (UTF-16)
* strings — including lone surrogates. A {@link SessionId} is an unvalidated branded string,
* so this neutralizes `../`, absolute paths, NUL, and separators before any filesystem use.
* Safe code units remain literal; every other unit, including `~`, becomes
* `~XXXX`. Operating on code units preserves lone surrogates, while special-
* casing `.` and `..` prevents traversal by an otherwise safe whole segment.
*
* @param raw - the string to encode; must be non-empty (throws on `''`).
* @returns the escaped single path segment, decodable back to `raw`.
@@ -132,9 +135,10 @@ export function eventLine(event: SessionEvent): string {
}
/**
* Parse a JSONL log buffer into its preserved event prefix (the header is line 0). Returns the
* longest prefix of complete, seq-contiguous events plus the byte offset of the end of the
* last preserved line (`committedBytes`).
* Parse a JSONL log buffer into its preserved event prefix (the header is line
* 0). Fully written events in an interrupted final turn remain part of the
* prefix. The first unparsable record or seq gap after the last `turn/end`
* marks a tolerated torn tail; the same hole in the committed region rejects.
*
* @param buffer - the raw bytes of the log file (header line first).
* @returns the header, the preserved event prefix, and `committedBytes` — the
@@ -142,9 +146,8 @@ export function eventLine(event: SessionEvent): string {
*/
export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionEvent[]; committedBytes: number } {
const text = buffer.toString('utf8')
// Split into complete (newline-terminated) lines, tracking the byte offset of each line's end
// so the truncation point is exact (multi-byte chars make the char offset differ from the
// byte offset).
// Track complete lines by byte offset: a non-newline tail is torn and ignored,
// and a running counter avoids rescanning a long multi-byte log.
const lines: { text: string; endByte: number }[] = []
let start = 0
let byteOffset = 0
@@ -172,8 +175,8 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE
}
const headerLine = parsedHeader
// Find the committed region: the prefix up to and including the LAST complete `turn/end` in
// the WHOLE log.
// Parse every complete record first so the last valid `turn/end` determines
// whether an earlier hole is committed corruption or an uncommitted tail.
interface Parsed { ok: boolean; event?: SessionEvent; endByte: number }
const parsed: Parsed[] = eventEntries.map((entry) => {
try {
@@ -191,8 +194,8 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE
if (p?.ok && p.event?.type === 'turn/end') { lastTurnEnd = i; break }
}
// Walk the longest PREFIX of complete, seq-contiguous, parseable event lines (line i is a
// parsed event with seq === i).
// Preserve the contiguous prefix, including a complete interrupted turn;
// holes through the last committed boundary throw, while later holes stop.
const preserved: SessionEvent[] = []
for (let i = 0; i < parsed.length; i++) {
const p = parsed[i]

View File

@@ -1,5 +1,7 @@
/**
* JSONL durable session-persistence backend (`@deepseek-ai/dsh-session-persistence-jsonl`).
* JSONL durable session-persistence backend. It stores a header and contiguous
* events in one append-only file per session, and delegates orchestration to
* {@link PersistenceCoordinator}.
* @module @deepseek-ai/dsh-session-persistence-jsonl
*/
@@ -66,7 +68,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
constructor(ctx: Context, public config: Config) {
super(ctx)
// Resolve the configured root to an ABSOLUTE path ONCE, here.
// Resolve once so later process.cwd() changes cannot split one backend across roots.
this.root = resolve(config.root)
this.coordinator = new PersistenceCoordinator<number>(this.ctx, this)
}
@@ -85,8 +87,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
return this.coordinator.load(id)
}
// `list` is BOTH the public service method and the PersistenceBackend hook — one method, the
// bucket walk below.
// One method serves both public `list` and the backend hook; delegating it to
// the coordinator would call this hook recursively.
/**
* The per-session init promises, exposed for white-box tests that await a
@@ -181,8 +183,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
await mkdir(dir, { recursive: true, mode: 0o700 })
await this.syncDir(this.root)
const finalPath = logPath(this.root, meta.cwd, meta.id)
// Never rename over an existing committed log: materialize is the FIRST write of a session
// the backend believes is new.
// Materialization is the first write; an existing log is an id collision.
/* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */
if (await this.exists(finalPath)) {
throw new Error(`refusing to materialize "${meta.id}": a log already exists on disk (load/resume it instead)`)
@@ -207,8 +208,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
await link(tmp, finalPath)
linked = true
} finally {
// If link failed, the temp is the only reference and must be removed before the original
// error propagates.
// Remove an unpublished temp on failure. After publication, defer cleanup
// until the directory entry is durable so cleanup cannot reject a live log.
/* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */
if (!linked) await rm(tmp, { force: true })
}
@@ -326,7 +327,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
const entries = await readdir(this.root, { withFileTypes: true })
return entries.filter(e => e.isDirectory()).map(e => `${this.root}/${e.name}`)
} catch (error) {
// ENOENT = the root has not been created yet → genuinely no sessions.
// Only an absent root means no sessions; rethrow every other I/O failure.
if (isENOENT(error)) return []
throw error
}

View File

@@ -54,7 +54,8 @@ runPersistenceContract('jsonl', async () => {
}
})
// Run the shared coordinator orchestration suite against the real JSONL backend.
// Two mounts share this temp root to exercise reload. `corruptTail` appends a partial,
// newline-less fragment past the committed region so coordinator repair runs on real file bytes.
runCoordinatorContract('jsonl', async (): Promise<CoordinatorFixture> => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-coord-'))
return {
@@ -344,7 +345,7 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
].join('\n') + '\n'
// No committed turn/end, so the gap is a tolerated crash boundary: scanLog PRESERVES the
// contiguous prefix (turn/start seq 0) — real interrupted-turn work, not discarded — and
// stops at the gap.
// stops at the gap. `loadCore`, not this scanner, later closes the orphaned turn.
expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0])
})
@@ -464,7 +465,8 @@ describe('SessionPersistenceJsonl: edge cases', () => {
})
it('list reads a header line longer than the 8KB read chunk', async () => {
// readFirstLine accumulates across reads when the first line exceeds its buffer.
// A tolerated extra field makes this valid header exceed the 8192-byte read buffer, proving
// `readFirstLine` accumulates chunks before `list()` parses it.
const bucket = join(root, '_no-cwd')
await mkdir(bucket, { recursive: true })
const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, pad: 'x'.repeat(9000) })
@@ -484,7 +486,8 @@ describe('SessionPersistenceJsonl: edge cases', () => {
for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s)
await sessFiberA.dispose()
// A NEW live Session object reuses id "reuse".
// A new Session object reuses the id. Object-keyed initialization must run independently,
// detect the disk collision, and reject instead of appending through session A's stale cursor.
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
let b!: Session
await ctx.plugin(Object.assign((inner: Context) => {
@@ -502,7 +505,9 @@ describe('SessionPersistenceJsonl: edge cases', () => {
await ctx.sessionPersistence.append(SessionId('x'), oneTurnLog())
await ctx.fiber.dispose()
// Backend 2 over the same root.
// Backend 2 creates a no-cwd session whose id exists only in `/w`. Exact `loadLive(id,
// undefined)` must not adopt across buckets; the any-cwd collision check then rejects instead
// of grafting no-cwd events onto a log with mismatched cwd.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
@@ -570,7 +575,8 @@ describe('SessionPersistenceJsonl: edge cases', () => {
})
it('list surfaces a non-ENOENT root error (ENOTDIR) instead of reporting no sessions', async () => {
// A durable backend must not collapse a storage fault to "no sessions".
// A durable backend must not collapse a storage fault to "no sessions". Making the root a
// regular file forces ENOTDIR from `readdir`, which must propagate.
const filePath = join(root, 'not-a-dir')
await writeFile(filePath, 'x')
const ctx2 = new Context()
@@ -581,8 +587,8 @@ describe('SessionPersistenceJsonl: edge cases', () => {
})
it('loadLive surfaces a non-ENOENT lookup error (ENOTDIR) instead of reporting absent', async () => {
// A non-ENOENT error from the per-id open() must surface, not be collapsed to "not found"
// (which would let live-adoption proceed under a false absence assumption).
// A non-ENOENT per-id open error must surface rather than become "not found" and permit false
// live adoption. Making the cwd bucket a regular file forces ENOTDIR for its child log path.
const cwd = '/x'
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
@@ -703,7 +709,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
const session = ctx.sessions.create(SessionId('reject-bad'))
// Serializability is enforced at the source: Session.append throws on a BigInt-bearing
// event before it enters session.events, so the durable log can never diverge from the live
// log.
// log. The error therefore surfaces synchronously at append, not later during backend flush.
expect(() => {
session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never, { surfaceOp: 'append' })
}).toThrow(/non-JSON-serializable/)

View File

@@ -1,5 +1,7 @@
/**
* SQLite durable session-persistence backend (`@deepseek-ai/dsh-session-persistence-sqlite`).
* SQLite durable session-persistence backend. It maps each session header and
* event to rows, and delegates write-path orchestration to
* {@link PersistenceCoordinator}.
* @module @deepseek-ai/dsh-session-persistence-sqlite
*/
@@ -75,8 +77,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
constructor(ctx: Context, public config: Config) {
super(ctx)
// Open the database asynchronously (the parent directory may need creating); every hook
// awaits `ready` first.
// Open asynchronously so directory creation does not block plugin apply;
// every storage hook awaits the same readiness promise.
this.ready = this.openDb(config.path, (config as Required<Config>).journalMode)
this.coordinator = new PersistenceCoordinator<number>(this.ctx, this)
}
@@ -105,8 +107,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
return this.coordinator.load(id)
}
// `list` is BOTH the public service method and the PersistenceBackend hook — one method (the
// SELECT below).
// One method serves both public `list` and the backend hook; delegating it to
// the coordinator would call this hook recursively.
/**
* The per-session init promises, exposed for white-box tests that await a

View File

@@ -56,7 +56,9 @@ export interface EventRow {
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
/**
* Open the database, validate its version, and apply schema and pragmas.
* Open the database and apply its schema and pragmas. A zero `user_version` is
* stamped with {@link SCHEMA_VERSION}; every other non-current version rejects
* rather than being migrated in place.
* @param path - the SQLite database file to open (created when absent).
* @param journalMode - validated journal pragma.
* @returns the open handle with pragmas applied and both tables ensured.
@@ -140,9 +142,10 @@ export function rowToEvent(row: EventRow): SessionEvent {
}
/**
* The preserved prefix of an ordered event-row list (mirrors the JSONL backend's `scanLog`):
* the longest prefix of complete, seq-contiguous, parseable rows, PLUS the seq from which a
* never-committed torn tail must be deleted (or `undefined` if the whole list is intact).
* Find the preserved prefix of ordered event rows. Fully written rows in an
* interrupted final turn remain in the prefix. The first unparsable row or seq
* gap after the last `turn/end` marks a tolerated torn tail; the same hole in
* the committed region rejects.
*
* @param rows - one session's event rows, ordered by seq ascending.
* @returns the preserved event prefix, plus `tornFrom` — the seq the physical
@@ -167,7 +170,8 @@ export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]
if (parsed[i]?.ok && rows[i]?.type === 'turn/end') { lastTurnEnd = i; break }
}
// Walk the longest PREFIX of complete, seq-contiguous, parseable rows (row i has seq === i).
// Preserve the contiguous prefix, including a complete interrupted turn;
// holes through the last committed boundary throw, while later holes stop.
const preserved: SessionEvent[] = []
for (let i = 0; i < rows.length; i++) {
const p = parsed[i]

View File

@@ -28,8 +28,7 @@ async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () =
return { ctx, dispose: () => fiber.dispose() }
}
// The payoff: the SAME backend-agnostic contract the JSONL backend runs, now
// proving the SQLite backend satisfies identical semantics.
// Run the same backend-agnostic contract as JSONL to pin identical semantics.
runPersistenceContract('sqlite', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -40,7 +39,8 @@ runPersistenceContract('sqlite', async () => {
}
})
// Run the shared coordinator orchestration suite against the real SQLite backend.
// A file-backed database lets two mounts share rows across reload. `corruptTail` inserts invalid
// JSON past the committed seq, exercising coordinator repair against real database rows.
runCoordinatorContract('sqlite', async (): Promise<CoordinatorFixture> => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-'))
const path = join(dir, 'sessions.db')
@@ -63,7 +63,8 @@ runCoordinatorContract('sqlite', async (): Promise<CoordinatorFixture> => {
describe('scanRows', () => {
// scanRows works off EventRows (data is a JSON string column); build them from SessionEvents
// so the unit tests read in terms of the event vocabulary.
// so the unit tests read in terms of the event vocabulary. Surface metadata is serialized to
// its nullable columns so the conversion remains faithful.
const rows = (events: SessionEvent[]): EventRow[] =>
events.map((e) => {
const se = e as SessionEvent<SurfaceEventType>
@@ -252,7 +253,8 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => {
// Two unmerged branches each shipped a DISTINCT layout under user_version 3 (one added only
// `seed_length`, the other only the surface columns).
// `seed_length`, the other only the surface columns). The merged v4 cannot interpret that
// ambiguous, incomplete layout and must reject it.
const path = await freshDbPath()
openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION (4)
const db = openDatabase(path, 'wal')
@@ -269,7 +271,9 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed: seqs 0..5
await b1.dispose()
// Hand-insert a torn tail row (seq 6, no closing turn/end) whose `data` is invalid JSON.
// A torn row after the last committed turn has invalid JSON. `scanRows` locates the boundary
// from seq/type columns without parsing the tail, preserves the committed prefix, and load
// deletes the row; invalid JSON inside the committed region would remain fatal.
const db = openDatabase(path, 'wal')
db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)')
.run(m.id, 'turn/start', '{not valid json')

View File

@@ -22,9 +22,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
## The write coordinator
The two first-party backends were byte-identical (or same-algorithm) for ALL of their write-path orchestration — the in-memory bookkeeping (per-id state, write-behind buffers, per-id serialization chains, per-session init promises), the `session/event` buffer → `session/flush` drain, lazy materialization, crash-tail repair on load, the four `session/created` adoption cases (new / HMR-adopt / collision / ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives differed (write bytes vs. INSERT rows).
`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its four public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice).
`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 four public service methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinator and storage):

View File

@@ -1,6 +1,7 @@
/**
* Shared buffering, serialization, adoption, repair, and disposal orchestration
* over backend-specific persistence primitives.
* for first-party backends. Third-party backends may implement the public
* persistence seam directly.
* @module @deepseek-ai/dsh-session-persistence/coordinator
*/
@@ -84,7 +85,11 @@ interface SessionState {
meta: SessionHeader
/** The next seq the backend expects to append (the stored log length). */
cursor: number
/** Whether lazy creation has produced a durable artifact. */
/**
* Whether lazy creation has produced a durable artifact. The first append
* atomically materializes the header with events; reclaim logic uses this to
* distinguish an unused id from a persisted collision.
*/
materialized: boolean
/**
* The live Session this state was bound to via `onCreated`, if any. State

View File

@@ -1,6 +1,7 @@
/**
* Durable session-persistence seam. Backends store {@link SessionEvent}s plus
* separate {@link SessionHeader} metadata.
* Durable session-persistence seam (`ctx.sessionPersistence`). Backends store
* {@link SessionEvent}s as the event-sourced log and carry non-replayable
* {@link SessionHeader} metadata separately.
* @module @deepseek-ai/dsh-session-persistence
*/
@@ -83,9 +84,9 @@ export abstract class SessionPersistence extends Service {
/**
* Load a header and balanced contiguous log. A complete interrupted final
* turn is preserved and closed with missing tool errors and boundary events;
* only a torn final record is discarded. Unknown versions and corruption in
* the committed prefix reject.
* turn is preserved and durably closed with missing tool errors plus any open
* step and turn boundaries; only a torn final record is discarded. Unknown
* versions and corruption in the committed prefix reject.
* @param id - the persisted session to reload.
* @returns the header and a log ending on a balanced `turn/end`.
*/

View File

@@ -43,8 +43,9 @@ export function oneTurnLog(): SessionEvent[] {
}
/**
* Append a whole event log to a LIVE session, event by event, forwarding the surface metadata
* each event already carries.
* Append recorded events to a live session while forwarding surface metadata verbatim. The broad
* `SessionEvent` union makes the typed marker optional, but the runtime guard must still reject a
* surface event whose fixture omitted it; this helper never synthesizes a default.
*/
export function appendLog(session: Session, events: readonly SessionEvent[]): void {
for (const e of events) {
@@ -217,7 +218,8 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
try {
// Every value `isJsonValue` rejects must be rejected by the backend, not just BigInt —
// otherwise a backend could pass this contract while still accepting values that
// corrupt the durable round-trip.
// corrupt the durable round-trip. Each value is carried in a plugin-added field on one
// user message so the contract covers the complete JSON-value boundary.
const cyclic: Record<string, unknown> = { type: 'text', text: 'x' }
cyclic['self'] = cyclic
const badValues: unknown[] = [

View File

@@ -1,5 +1,11 @@
/**
* Reusable ORCHESTRATION suite for any backend that composes a {@link PersistenceCoordinator}.
* Shared write-path orchestration contract for backends using {@link PersistenceCoordinator}.
* Unlike the public storage-semantics suite in `contract.ts`, it covers SessionStore event wiring,
* lazy creation, fork seed persistence, four adoption/collision cases, crash-tail repair, reload,
* flush, and disposal quiescence through public APIs rather than storage primitives.
*
* Each real backend supplies a shared storage scope and optional torn-tail injector; backend specs
* retain only storage-mechanics tests, while these scenarios run once per backend.
* @module @deepseek-ai/dsh-session-persistence/tests/coordinator-contract
*/
@@ -16,10 +22,13 @@ import { meta, oneTurnLog, appendLog } from './contract.ts'
* the suite mounts/disposes backend instances on it and cleans it up at the end.
*/
export interface CoordinatorFixture {
/** Mount a backend over shared fixture storage and return its disposable fiber. */
/** Mount the real backend through `ctx.plugin` over shared storage and return only that fiber. */
mount: (ctx: Context) => Promise<Fiber>
/** Inject an uncommitted torn tail; absent for backends that cannot produce one. */
/**
* Inject a never-committed partial record after the durable region so `loadCore` reaches
* `commitRepair`. Omit only when the backend structurally cannot produce torn tails.
*/
corruptTail?: (id: SessionId, cwd: string | undefined) => Promise<void>
/** Tear down the storage scope (remove the temp dir / file). */
@@ -87,7 +96,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
it('round-trips the seed boundary (seedLength) through persistence', async () => {
// A forked child records how many leading events were inherited via the seed; the
// boundary must survive a reload (so a resume/replay can tell the inherited prefix from
// the child's own events).
// the child's own events). JSONL stores it in the header; SQLite uses `seed_length`.
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
@@ -173,10 +182,10 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
})
it('resume: a re-created session seeded with the loaded log does not re-append its seed and continues the seq', async () => {
// Separate backend lifecycles distinguish persisted-seed adoption from an in-memory continuation.
const fix = await makeFixture()
const first = await freshCtx(fix)
try {
// First lifecycle: persist a session through the store.
const s1 = first.ctx.sessions.create(SessionId('resumed'), { meta: { cwd: WORK } })
send(s1, oneTurnLog())
await first.ctx.parallel('session/flush', s1)
@@ -184,9 +193,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await first.fiber.dispose()
}
// Second lifecycle: a NEW backend instance + a session re-created with the
// same id SEEDED with the loaded events. onCreated adopts the stored log
// (does not re-persist the seed); a new turn appends at seq 6.
const second = await freshCtx(fix)
try {
const loaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
@@ -197,7 +203,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await second.ctx.parallel('session/flush', s2)
const reloaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
// 6 original + 2 new, contiguous, no duplicated seed.
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
} finally {
await second.fiber.dispose()
@@ -265,7 +270,8 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
await ctx.parallel('session/flush', session)
// Hot-reload: dispose instance 1, mount instance 2 over the same storage while the
// session stays live.
// session stays live. The new instance has no coordinator state but must adopt the
// materialized prefix, then persist another turn rather than rejecting it as a collision.
await backend1.dispose()
await fix.mount(ctx)
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -358,7 +364,8 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
// A fresh backend + a NEW live session with the same id but NO explicit resume. onCreated
// treats it as new; create() rejects because a log already exists.
// treats it as new; create() rejects because a log already exists, and `flush()` surfaces
// that initialization rejection.
const second = await freshCtx(fix)
try {
const s2 = second.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } })

View File

@@ -16,8 +16,10 @@ type MemoryStore = Map<string, { meta: SessionHeader; events: SessionEvent[] }>
interface MemoryConfig { store?: MemoryStore }
/**
* A trivial in-memory {@link SessionPersistence} that composes a {@link
* PersistenceCoordinator} over a dependency-free `Map`-backed {@link PersistenceBackend}.
* 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
* instances share materialized sessions, the in-memory analogue of reload over one file/database;
* durable behavior is covered by the JSONL and SQLite backends.
*/
class MemoryPersistence extends SessionPersistence implements PersistenceBackend<never> {
static inject = ['sessions']
@@ -78,8 +80,7 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
}
const existing = this.store.get(m.id)
if (!existing) {
// First batch: `_isMaterialized` is false (the coordinator only omits
// materialization on the first batch); writing the entry IS the materialization.
// The coordinator sends the first batch for materialization; later batches append.
this.store.set(m.id, { meta: structuredClone(m), events: structuredClone(events) as SessionEvent[] })
} else {
existing.events.push(...structuredClone(events) as SessionEvent[])
@@ -112,7 +113,8 @@ runPersistenceContract('memory', async () => {
}
})
// Run the shared coordinator orchestration suite against the in-memory backend.
// Each fixture shares one map across mounts. No `corruptTail` is supplied because map writes are
// atomic; the suite asserts that skip while JSONL and SQLite cover the repair branch.
runCoordinatorContract('memory', async (): Promise<CoordinatorFixture> => {
const store: MemoryStore = new Map()
return {