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

# Conflicts:
#	docs/architecture.i18n.yaml
#	packages/client/ui-sidebar/tests/sidebar-root.spec.tsx
#	packages/session-persistence/session-persistence-jsonl/src/index.ts
#	packages/session-persistence/session-persistence/README.md
This commit is contained in:
Hypatia May
2026-07-23 21:13:29 +08:00
461 changed files with 21288 additions and 6015 deletions
@@ -37,14 +37,13 @@ The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinato
| Hook | Role |
|---|---|
| `name` | Backend label for the dispose-failure `AggregateError`. |
| `loadStored(id)` | Read a stored prefix by id, scanning ANY storage scope. Used by resume/load and, via `!== undefined`, the create-collision probe. Returns an opaque `tornMarker` iff a torn tail must be truncated. |
| `loadLive(id, cwd)` | Read a stored prefix SCOPED to `cwd` (HMR live-adoption must only adopt a log at the SAME cwd; a same-id log elsewhere is a collision, not a resume). A globally-unique-id backend ignores `cwd`. |
| `loadStored(id)` | Read a stored prefix by id across every storage scope. Used by resume/load, live adoption, and the create-collision probe. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. |
| `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. |
| `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). |
| `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 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. 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
@@ -35,22 +35,14 @@ export interface PersistenceBackend<TornMarker = unknown> {
readonly name: string
/**
* Read a stored prefix by id, scanning ANY storage scope (for JSONL: every
* cwd bucket). Returns `undefined` if no stored artifact exists. Used by
* resume/load, and — via `!== undefined` — by the create-collision probe.
* The returned `tornMarker` is present iff there is a torn tail to truncate.
* Read a stored prefix by id, scanning every backend storage scope. Returns
* `undefined` if no stored artifact exists. Returned metadata must identify
* `id` before repair or state publication. Used by resume/load, live adoption,
* and — via `!== undefined` — the create-collision probe. The returned
* `tornMarker` is present iff there is a torn tail to truncate.
*/
loadStored(id: SessionId): Promise<StoredPrefix<TornMarker> | undefined>
/**
* Read a stored prefix SCOPED to `cwd`. Deliberately distinct from
* {@link loadStored}: HMR live-adoption must only adopt a persisted log at the
* SAME cwd as the live session (a same-id log at a different cwd is a
* collision, not a resume) — conflating the two reintroduces a cross-cwd
* adoption bug. For a globally-unique-id backend (SQLite) `cwd` is ignored.
*/
loadLive(id: SessionId, cwd: string | undefined): Promise<StoredPrefix<TornMarker> | undefined>
/**
* Durably append a CONTIGUOUS batch, lazily materializing the session first
* when `!isMaterialized`. The materialize-write and the first event batch MUST
@@ -264,6 +256,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
const stored = await this.backend.loadStored(id)
if (stored === undefined) throw new Error(`session "${id}" not found`)
const { meta, events, tornMarker } = stored
this.assertStoredId(id, meta)
this.assertVersion(meta)
assertSupportedEvents(events, id)
@@ -322,6 +315,13 @@ export class PersistenceCoordinator<TornMarker = unknown> {
}
}
/** Reject backend metadata that is not bound to the requested session id. */
private assertStoredId(id: SessionId, meta: SessionHeader): void {
if (meta.id !== id) {
throw new Error(`stored session identity mismatch: requested "${id}", header contains "${meta.id}"`)
}
}
// --- write path (session/event → flush drain) ---
private installWritePath(): void {
@@ -444,6 +444,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
const stored = await this.backend.loadStored(id)
/* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */
if (stored === undefined) return false
this.assertStoredId(id, stored.meta)
return seedCoversPrefix(seed, stored.events.slice(0, cursor))
}
@@ -453,9 +454,10 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* Cases, by whether this backend tracks the id and whether an artifact exists:
* 1. Already tracked → no-op (or claim ownerless state if the seed matches,
* or reclaim a truly-abandoned id, else reject as a collision).
* 2. Not tracked, an artifact EXISTS at this cwd and is a seq-aligned PREFIX
* of the live events → ADOPT it (HMR/reload), persisting any live suffix.
* 3. Not tracked, an artifact EXISTS but is NOT a prefix → REJECT (collision).
* 2. Not tracked, an artifact EXISTS at the same cwd and is a seq-aligned
* PREFIX of the live events → ADOPT it, persisting any live suffix.
* 3. Not tracked, an artifact EXISTS at another cwd or is NOT a prefix →
* REJECT (collision).
* 4. Not tracked and NO artifact → a genuinely new session: register meta
* (lazy) and persist its seed once.
*/
@@ -469,14 +471,11 @@ export class PersistenceCoordinator<TornMarker = unknown> {
if (tracked.owner === undefined) {
// Ownerless state from the public create()/load() API. The FIRST live
// session claims it — but ONLY if BOTH the cwd scope and the seed match.
// The cwd guard mirrors case-2's cwd-scoped loadLive(): a same-id
// ownerless artifact at a DIFFERENT cwd is a collision, not a claim
// (claiming it would append the live cwd's events under the stored
// header's cwd, the exact cross-cwd corruption the loadLive scope
// prevents). The seed guard then ensures the live events reproduce the
// persisted prefix (else a fresh, unrelated session reusing the id would
// have its seq 0..cursor-1 events filtered as already-written and
// grafted on).
// A same-id ownerless artifact at a different cwd is a collision, not a
// claim: accepting it would append this live session's events through
// the stored header's cwd. The seed guard then ensures the live events
// reproduce the persisted prefix; otherwise a fresh session reusing the
// id could have its leading events filtered as already written.
if (tracked.meta.cwd !== session.header.cwd) {
throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
}
@@ -500,11 +499,9 @@ export class PersistenceCoordinator<TornMarker = unknown> {
}
}
// case 2/3: an artifact at THIS cwd is adopted as a live prefix (or rejected
// as a collision inside adoptLivePrefix). cwd-scoped (loadLive), never
// any-scope: a same-id artifact at a different cwd is a collision, not a
// resume.
const live = await this.backend.loadLive(id, session.header.cwd)
// case 2/3: resolve the id once across storage, then let adoption reject a
// cwd mismatch before repair or state publication.
const live = await this.backend.loadStored(id)
if (live !== undefined) {
// Do NOT route through loadCore(): that crash-repairs open turns as
// interrupted, which is wrong for HMR while the live Session is still the
@@ -533,6 +530,10 @@ export class PersistenceCoordinator<TornMarker = unknown> {
*/
private async adoptLivePrefix(session: Session, seed: readonly SessionEvent[], stored: StoredPrefix<TornMarker>): Promise<void> {
const { meta, events, tornMarker } = stored
this.assertStoredId(session.header.id, meta)
if (meta.cwd !== session.header.cwd) {
throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
}
this.assertVersion(meta)
assertSupportedEvents(events, session.header.id)
if (!seedCoversPrefix(seed, events)) {
@@ -73,7 +73,7 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
super(ctx)
// Assign the store BEFORE constructing the coordinator: the coordinator's
// constructor installs the write path and synchronously seeds existing live
// sessions (onCreated → loadLive → this.store), so store must exist first.
// sessions through loadStored(), so store must exist first.
this.store = config?.store ?? new Map<string, { meta: SessionHeader; events: SessionEvent[] }>()
this.coordinator = new PersistenceCoordinator<never>(this.ctx, this)
}
@@ -98,18 +98,13 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
// --- PersistenceBackend hooks (the Map storage primitives) ---
// A Map-backed store has no torn tails, so `tornMarker` is never set. Ids are
// globally unique, so loadStored and loadLive are identical (cwd is ignored).
// A Map-backed store has no torn tails, so `tornMarker` is never set.
async loadStored(id: SessionId): Promise<StoredPrefix<never> | undefined> {
const entry = this.store.get(id)
if (!entry) 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> {
// Defense-in-depth: the coordinator already validates serializability, but a
// durable store must reject non-JSON data at its own boundary too.
@@ -154,6 +149,7 @@ class ControlledBackend implements PersistenceBackend<never> {
readonly lifecycle: string[] = []
appendAttempts = 0
loadAttempts = 0
repairAttempts = 0
beforeAppend?: (attempt: number) => Promise<void>
beforeLoadStored?: (attempt: number) => Promise<void>
@@ -164,10 +160,6 @@ class ControlledBackend implements PersistenceBackend<never> {
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)
@@ -179,7 +171,9 @@ class ControlledBackend implements PersistenceBackend<never> {
}
}
async commitRepair(_m: SessionHeader, _tornMarker: undefined, _closers: readonly SessionEvent[]): Promise<void> {}
async commitRepair(_m: SessionHeader, _tornMarker: undefined, _closers: readonly SessionEvent[]): Promise<void> {
this.repairAttempts += 1
}
async list(): Promise<SessionHeader[]> {
return [...this.store.values()].map(entry => structuredClone(entry.meta))
@@ -211,6 +205,36 @@ runCoordinatorContract('memory', async (): Promise<CoordinatorFixture> => {
}
})
describe('PersistenceCoordinator stored identity', () => {
it('rejects a mismatched backend header before repair or state publication', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
const requested = SessionId('requested')
backend.store.set(requested, {
meta: meta('different'),
events: [{
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
}],
})
let coordinator!: PersistenceCoordinator<never>
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
try {
await expect(coordinator.load(requested)).rejects.toThrow(/stored session identity mismatch/)
expect(backend.repairAttempts).toBe(0)
expect((coordinator as unknown as CoordinatorInternals).states.size).toBe(0)
} finally {
await fiber.dispose()
await ctx.fiber.dispose()
}
})
})
describe('PersistenceCoordinator retirement', () => {
it('a retiring unmaterialized owner without buffered events releases its id', async () => {
const ctx = new Context()