fix(session-query): qualify persistence revisions by store

This commit is contained in:
Hypatia May
2026-07-15 12:32:18 +08:00
parent f88ca85ffd
commit 35edf2a825
13 changed files with 192 additions and 31 deletions
@@ -25,7 +25,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
- **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`.
- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md).
- **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type.
- **Lightweight revisions.** `listSnapshots()` reads each header and returns an opaque identity from the file device/inode, size, and nanosecond mtime/ctime. Under the append-only single-writer contract, unchanged files retain revisions while append, repair, or replacement changes them without parsing event lines.
- **Lightweight revisions.** `listSnapshots()` reads each header and returns an opaque identity from the file device/inode, size, and nanosecond mtime/ctime. Under the append-only single-writer contract, unchanged files retain revisions while append, repair, replacement, or switching to an independent root changes them without parsing event lines.
- **Format version.** Only the current `SESSION_FORMAT_VERSION` (v0) is supported; `load` rejects any other version. While the harness is unreleased the on-disk format is pre-release/unstable: a breaking format change is absorbed at v0 (no bump until the first tagged release) and non-current logs are rejected — there is no migration (no persisted user data to preserve).
## Write path
@@ -147,6 +147,29 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
expect(loaded.events).toEqual(log) // chunks preserved, contiguous seqs
})
it('source-qualifies revisions across roots while preserving same-log reopen identity', async () => {
const m = meta('revision-source')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
const revision = (await ctx.sessionPersistence.listSnapshots())[0]?.revision
const reopenedCtx = new Context()
await reopenedCtx.plugin(SessionStore)
await reopenedCtx.plugin(SessionPersistenceJsonl, { root })
expect((await reopenedCtx.sessionPersistence.listSnapshots())[0]?.revision).toBe(revision)
const otherRoot = await freshRoot()
const otherCtx = new Context()
await otherCtx.plugin(SessionStore)
await otherCtx.plugin(SessionPersistenceJsonl, { root: otherRoot })
await otherCtx.sessionPersistence.create(m)
await otherCtx.sessionPersistence.append(m.id, oneTurnLog())
expect((await otherCtx.sessionPersistence.listSnapshots())[0]?.revision).not.toBe(revision)
await reopenedCtx.fiber.dispose()
await otherCtx.fiber.dispose()
})
it('persists a forked child seed through the existing session write path', async () => {
const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } })
appendClosedTurn(source)