124 lines
4.9 KiB
TypeScript
124 lines
4.9 KiB
TypeScript
/**
|
|
* Crash-recovery repair for an interrupted session log. It preserves a fully
|
|
* written final turn and supplies the missing tool, step, and turn boundaries
|
|
* needed to resume with a provider-valid transcript.
|
|
* @module @deepseek-ai/dsh-session/repair
|
|
*/
|
|
|
|
import type { CallId } from '@deepseek-ai/dsh-llm'
|
|
import type { SessionEvent } from './types.ts'
|
|
|
|
/** Recovery code for an assistant tool request that never reached a recorded call start. */
|
|
export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'
|
|
|
|
/** Recovery code for a recorded tool call whose completed outcome was not durably recorded. */
|
|
export const TOOL_OUTCOME_UNKNOWN = 'TOOL_OUTCOME_UNKNOWN'
|
|
|
|
/**
|
|
* Return deterministic synthetic events that close an open tail turn. Unmatched
|
|
* calls receive error results first, followed by an open `step/end` and an
|
|
* interrupted `turn/end`; sequences continue the log and timestamps reuse the
|
|
* last real event. A balanced or empty log returns no events.
|
|
*
|
|
* @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
|
|
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
|
|
*/
|
|
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
|
|
let openTurn: number | null = null
|
|
let openStep: number | null = null
|
|
// Reset at each turn boundary so earlier calls cannot leak into tail repair.
|
|
// Assistant blocks register calls; later tool/call events add provenance seqs.
|
|
const pendingCalls = new Map<CallId, { step: number; callSeq?: number }>()
|
|
for (const event of events) {
|
|
switch (event.type) {
|
|
case 'turn/start':
|
|
openTurn = event.data.turn
|
|
openStep = null
|
|
pendingCalls.clear()
|
|
break
|
|
case 'turn/end':
|
|
openTurn = null
|
|
openStep = null
|
|
pendingCalls.clear()
|
|
break
|
|
case 'step/start':
|
|
openStep = event.data.step
|
|
break
|
|
case 'step/end':
|
|
pendingCalls.clear()
|
|
openStep = null
|
|
break
|
|
case 'assistant/message':
|
|
// The assistant message carries the tool-call blocks; each is pending
|
|
// until a tool/result event with the same callId is logged.
|
|
for (const block of event.data.content) {
|
|
if (block.type === 'tool-call') pendingCalls.set(block.id, { step: event.data.step })
|
|
}
|
|
break
|
|
case 'tool/call':
|
|
// Add the tool/call seq used as provenance on a synthetic result.
|
|
{
|
|
const entry = pendingCalls.get(event.data.callId)
|
|
if (entry) {
|
|
entry.callSeq = event.seq
|
|
}
|
|
}
|
|
break
|
|
case 'tool/result':
|
|
pendingCalls.delete(event.data.callId)
|
|
break
|
|
// Other event types do not move the turn/step boundary cursor.
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// Balanced log (no crash mid-turn): nothing to close. An open turn implies
|
|
// `events` is non-empty (its turn/start was logged), so `last` exists.
|
|
const last = events.at(-1)
|
|
if (openTurn === null || last === undefined) return []
|
|
|
|
// The last real event supplies the seq base and the timestamp for the
|
|
// synthetic closers (reusing the last timestamp keeps them deterministic and
|
|
// never invents a "future" time).
|
|
let seq = last.seq + 1
|
|
const time = last.time
|
|
const closers: SessionEvent[] = []
|
|
|
|
// Close calls before their step: providers reject dangling assistant calls,
|
|
// and Map insertion order preserves their transcript order.
|
|
for (const [callId, { step, callSeq }] of pendingCalls) {
|
|
const started = callSeq !== undefined
|
|
closers.push({
|
|
type: 'tool/result',
|
|
seq: seq++,
|
|
time,
|
|
data: {
|
|
turn: openTurn,
|
|
step,
|
|
callId,
|
|
content: [{
|
|
type: 'text',
|
|
text: started
|
|
? 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.'
|
|
: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
|
|
}],
|
|
isError: true,
|
|
error: started
|
|
? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN }
|
|
: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
|
|
},
|
|
surfaceOp: 'append',
|
|
...started ? { sourceEventSeqs: [callSeq] } : {},
|
|
})
|
|
}
|
|
|
|
// Close an open step next — a turn/end while a step is open is an invariant
|
|
// violation, so the step's boundary must be synthesized before the turn's.
|
|
if (openStep !== null) {
|
|
closers.push({ type: 'step/end', seq: seq++, time, data: { turn: openTurn, step: openStep } })
|
|
}
|
|
closers.push({ type: 'turn/end', seq: seq++, time, data: { turn: openTurn, reason: { kind: 'interrupted' } } })
|
|
return closers
|
|
}
|