fix: quiesce cancelled session reconciliation

This commit is contained in:
Hypatia May
2026-07-24 21:27:07 +08:00
parent cbc6d81fc3
commit 224e00b2bd
20 changed files with 359 additions and 31 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)