fix(persistence): tighten crash repair and dispose semantics

This commit is contained in:
Tianyi Cui
2026-06-17 21:26:21 +08:00
parent 87df09e3c3
commit 2b36620e55
10 changed files with 161 additions and 40 deletions

View File

@@ -57,6 +57,10 @@ interface SessionTrace {
openTurn: number | null
/** Open step within the current turn, or null between steps. */
openStep: number | null
/** The next turn number expected in this session log. */
nextTurn: number
/** The next step number expected within the open turn. */
nextStep: number
/**
* Tool-call ids issued in the OPEN step awaiting a result. Cleared at
* `step/end` — a result must arrive in the same step as its call.
@@ -114,7 +118,11 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
if (trace.openTurn !== null) {
throw new InvariantError(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`)
}
if (event.data.turn !== trace.nextTurn) {
throw new InvariantError(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`)
}
trace.openTurn = event.data.turn
trace.nextStep = 1
break
}
case 'turn/end': {
@@ -125,6 +133,7 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
throw new InvariantError(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`)
}
trace.openTurn = null
trace.nextTurn += 1
break
}
case 'step/start': {
@@ -134,6 +143,9 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
if (trace.openStep !== null) {
throw new InvariantError(`step/start ${event.data.step} while step ${trace.openStep} is still open`)
}
if (event.data.step !== trace.nextStep) {
throw new InvariantError(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`)
}
trace.openStep = event.data.step
break
}
@@ -143,6 +155,7 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
// (a step that errored before its result) do not carry to the next step.
trace.pendingCalls.clear()
trace.openStep = null
trace.nextStep += 1
break
}
case 'assistant/chunk': {
@@ -163,7 +176,8 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
// A result needs a prior matching call in the same step. (The converse
// does NOT hold: a call may have no result — a throwing tools/execute
// waterfall ends the step with no tool/result, which is legal.)
if (!trace.pendingCalls.delete(event.data.callId)) {
const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
if (!trace.pendingCalls.delete(event.data.callId) && !syntheticInterrupted) {
throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
}
break
@@ -216,7 +230,14 @@ export function apply(ctx: Context, config: Config = {}): void {
// (re-)apply seeds the baseline, so a reload never produces a false positive.
const lastStatus = new WeakMap<Agent, AgentStatus>()
const freshTrace = (): SessionTrace => ({ lastSeq: -1, openTurn: null, openStep: null, pendingCalls: new Set() })
const freshTrace = (): SessionTrace => ({
lastSeq: -1,
openTurn: null,
openStep: null,
nextTurn: 1,
nextStep: 1,
pendingCalls: new Set(),
})
/** Build (or rebuild) a session's trace by replaying its whole log; freeze it. */
const seedSession = (session: Session): SessionTrace => {

View File

@@ -126,6 +126,28 @@ describe('session-log invariants', () => {
.toThrow(/no prior tool\/call/)
})
it('allows a synthetic interrupted tool/result from crash repair without a prior tool/call event', async () => {
const { ctx } = await setup({ freeze: false })
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('crashed'), name: 'bash', arguments: '{}' },
] })
session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('crashed'),
content: [{ type: 'text', text: 'interrupted' }],
isError: true,
error: { name: 'InterruptedError', code: 'interrupted' },
})
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
}).not.toThrow()
})
it('allows a tool/call with no matching tool/result (thrown waterfall ends the step)', async () => {
const { ctx } = await setup({ freeze: false })
const session = ctx.sessions.create()
@@ -177,6 +199,25 @@ describe('session-log invariants', () => {
}).not.toThrow()
})
it('rejects a skipped turn number', async () => {
const { ctx } = await setup({ freeze: false })
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(() => session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } }))
.toThrow(/expected turn 2, got 3/)
})
it('rejects a skipped step number within a turn', async () => {
const { ctx } = await setup({ freeze: false })
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('step/end', { turn: 1, step: 1 })
expect(() => session.append('step/start', { turn: 1, step: 3 }))
.toThrow(/expected step 2 in turn 1, got 3/)
})
it('rejects a turn/end while a step is still open', async () => {
const { ctx } = await setup({ freeze: false })
const session = ctx.sessions.create()