fix: quiesce cancelled session reconciliation
This commit is contained in:
@@ -39,7 +39,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the
|
||||
- **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects.
|
||||
- **Non-mutating inspection.** `inspect()` returns the detached valid prefix without truncating an incomplete tail or closing an interrupted turn, and leaves the lightweight revision unchanged.
|
||||
- **Contiguous-seq.** `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()` identifies a log by its device, inode, size, and nanosecond timestamps, avoiding a full-log parse while changing after append, repair, replacement, or store changes.
|
||||
- **Lightweight revisions.** `listSnapshots(signal?)` identifies a log by its device, inode, size, and nanosecond timestamps, avoiding a full-log parse while changing after append, repair, replacement, or store changes. It forwards the exact signal through artifact discovery and checks cancellation around every `stat`; because filesystem `stat` is not interruptible, cancellation waits for the active call to settle, then rejects without starting another.
|
||||
|
||||
## Write path
|
||||
|
||||
|
||||
@@ -281,11 +281,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
|
||||
/** List metadata plus a stat-derived identity for each append-only log. */
|
||||
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
|
||||
async listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]> {
|
||||
const snapshots: SessionPersistenceSnapshot[] = []
|
||||
for (const artifact of await this.listArtifacts()) {
|
||||
for (const artifact of await this.listArtifacts(signal)) {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
const identity = await stat(artifact.path, { bigint: true })
|
||||
signal?.throwIfAborted()
|
||||
snapshots.push({
|
||||
header: artifact.header,
|
||||
revision: SessionPersistenceRevision([
|
||||
@@ -297,9 +299,11 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
].join(':')),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
if (!isENOENT(error)) throw error
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
return snapshots
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,56 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
discovery.mockRestore()
|
||||
})
|
||||
|
||||
it('forwards snapshot-list cancellation and awaits in-flight discovery cleanup', async () => {
|
||||
const persistence = ctx.sessionPersistence as unknown as {
|
||||
listArtifacts(signal?: AbortSignal): Promise<Array<{ header: SessionHeader; path: string }>>
|
||||
}
|
||||
const started = Promise.withResolvers<AbortSignal>()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
vi.spyOn(persistence, 'listArtifacts').mockImplementation(async (signal) => {
|
||||
if (signal === undefined) throw new Error('expected snapshot-list signal')
|
||||
started.resolve(signal)
|
||||
await cleanup.promise
|
||||
return []
|
||||
})
|
||||
const reason = new Error('JSONL snapshot discovery cancelled')
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.sessionPersistence.listSnapshots(controller.signal)
|
||||
expect(await started.promise).toBe(controller.signal)
|
||||
let settled = false
|
||||
void pending.then(
|
||||
() => { settled = true },
|
||||
() => { settled = true },
|
||||
)
|
||||
|
||||
controller.abort(reason)
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
cleanup.resolve(undefined)
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
})
|
||||
|
||||
it('checks cancellation after an uncancellable snapshot stat settles', async () => {
|
||||
const m = meta('snapshot-stat-cancellation')
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||
const persistence = ctx.sessionPersistence as unknown as {
|
||||
listArtifacts(signal?: AbortSignal): Promise<Array<{ header: SessionHeader; path: string }>>
|
||||
}
|
||||
const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{
|
||||
header: m,
|
||||
path: rawLogPath(root, m.cwd, m.id),
|
||||
}])
|
||||
const reason = new Error('JSONL snapshot stat cancelled')
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.sessionPersistence.listSnapshots(controller.signal)
|
||||
queueMicrotask(() => { controller.abort(reason) })
|
||||
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
expect(discovery).toHaveBeenCalledWith(controller.signal)
|
||||
})
|
||||
|
||||
it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
|
||||
const m = meta('legacy-header-delta', '/legacy')
|
||||
const path = rawLogPath(root, m.cwd, m.id)
|
||||
|
||||
@@ -20,7 +20,7 @@ On filesystems with POSIX modes, the backend requests mode `0700` for missing di
|
||||
- **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row).
|
||||
- **Interrupted-turn close on load.** `load()` implements the shared [crash-recovery contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md): preserve the valid interrupted turn, append its synthetic closing events in one transaction, and remove only a torn tail row. Committed parse errors or sequence gaps make the session unloadable. Because recovery mutates stored rows, the next append starts from a balanced log and accurate cursor.
|
||||
- **Non-mutating inspection.** `inspect()` returns the detached valid row prefix without deleting a torn tail row or appending recovery closers, and leaves the lightweight revision unchanged.
|
||||
- **Lightweight revisions.** `listSnapshots()` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs.
|
||||
- **Lightweight revisions.** `listSnapshots(signal?)` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. It checks cancellation before and after shared readiness and the synchronous metadata query; the query itself is non-preemptible.
|
||||
|
||||
## Configuration (schemastery)
|
||||
|
||||
|
||||
@@ -266,9 +266,12 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
}
|
||||
|
||||
/** List metadata with a source-qualified monotonic revision per session. */
|
||||
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
|
||||
async listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]> {
|
||||
signal?.throwIfAborted()
|
||||
await this.ready
|
||||
signal?.throwIfAborted()
|
||||
const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[]
|
||||
signal?.throwIfAborted()
|
||||
return rows.map(row => ({
|
||||
header: rowToMeta(row),
|
||||
revision: SessionPersistenceRevision(
|
||||
|
||||
@@ -441,6 +441,31 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
await second.dispose()
|
||||
})
|
||||
|
||||
it('awaits in-flight readiness before surfacing snapshot-list cancellation', async () => {
|
||||
const b = await backend()
|
||||
const internals = b.ctx.sessionPersistence as unknown as { ready: Promise<void> }
|
||||
const originalReady = internals.ready
|
||||
const readiness = Promise.withResolvers<undefined>()
|
||||
internals.ready = readiness.promise
|
||||
const reason = new Error('SQLite snapshot readiness cancelled')
|
||||
const controller = new AbortController()
|
||||
const pending = b.ctx.sessionPersistence.listSnapshots(controller.signal)
|
||||
let settled = false
|
||||
void pending.then(
|
||||
() => { settled = true },
|
||||
() => { settled = true },
|
||||
)
|
||||
|
||||
controller.abort(reason)
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
readiness.resolve(undefined)
|
||||
await expect(pending).rejects.toBe(reason)
|
||||
internals.ready = originalReady
|
||||
await b.dispose()
|
||||
})
|
||||
|
||||
it('exposes the schema version constant', () => {
|
||||
expect(SCHEMA_VERSION).toBe(8)
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
|
||||
| `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and unknown `version` reject. |
|
||||
| `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. |
|
||||
| `list(signal?): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. |
|
||||
| `listSnapshots(): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. |
|
||||
| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. |
|
||||
|
||||
## Invariants every backend must honor
|
||||
|
||||
@@ -33,7 +33,7 @@ Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative
|
||||
|
||||
When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle.
|
||||
|
||||
The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration.
|
||||
The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. `listSnapshots(signal?)` passes the caller's exact signal into backend discovery so observers can cancel that work without detaching it.
|
||||
|
||||
The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinator and storage):
|
||||
|
||||
|
||||
@@ -122,9 +122,10 @@ export abstract class SessionPersistence extends Service {
|
||||
* successful mutating {@link load} repair changes the next listed revision.
|
||||
* Revisions also distinguish independently backed stores so backend-local
|
||||
* counters cannot compare equal across different persistence sources.
|
||||
* @param signal - optional cancellation for backend snapshot-listing work.
|
||||
* @returns one header and opaque revision per materialized session without loading full logs.
|
||||
*/
|
||||
abstract listSnapshots(): Promise<SessionPersistenceSnapshot[]>
|
||||
abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]>
|
||||
}
|
||||
|
||||
export default SessionPersistence
|
||||
|
||||
@@ -227,9 +227,11 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
try {
|
||||
const reason = new Error('persistence observation cancelled')
|
||||
const controller = new AbortController()
|
||||
await expect(persistence.listSnapshots(controller.signal)).resolves.toEqual([])
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(persistence.list(controller.signal)).rejects.toBe(reason)
|
||||
await expect(persistence.listSnapshots(controller.signal)).rejects.toBe(reason)
|
||||
await expect(persistence.inspect(SessionId('cancelled-inspect'), controller.signal))
|
||||
.rejects.toBe(reason)
|
||||
} finally {
|
||||
|
||||
@@ -137,7 +137,8 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
|
||||
return [...this.store.values()].map(e => structuredClone(e.meta))
|
||||
}
|
||||
|
||||
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
|
||||
async listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]> {
|
||||
signal?.throwIfAborted()
|
||||
return [...this.store.values()].map(entry => ({
|
||||
header: structuredClone(entry.meta),
|
||||
revision: SessionPersistenceRevision(`events:${entry.events.length}`),
|
||||
|
||||
Reference in New Issue
Block a user