153 lines
6.3 KiB
TypeScript
153 lines
6.3 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, plus the activity-time
|
|
* read that must skip the end-seed boundary — which this module does
|
|
* not write (`Session`'s constructor does) but whose synthetic closers can
|
|
* inherit that boundary's timestamp, the one real coupling between the two.
|
|
* @module @deepseek-ai/dsh-session/repair
|
|
*/
|
|
|
|
import { MessageId, freezeMessage, type CallId } from '@deepseek-ai/dsh-llm'
|
|
import type { ToolResultMessage } from '@deepseek-ai/dsh-llm'
|
|
import type { SessionEvent } from './types.ts'
|
|
|
|
/**
|
|
* The `time` of the log's last event representing actual work, skipping the
|
|
* `session/end-seed` boundary — picking a session up is not activity, so
|
|
* activity ordering must exclude it.
|
|
*
|
|
* Excluded by type, so a pickup time still leaks when a boundary is the last
|
|
* event of an open turn: {@link interruptedTurnClosers} copies it onto the
|
|
* synthetic `turn/end`, which this counts as work. Reachable only by seeding an
|
|
* unbalanced log directly — `load()` balances first.
|
|
* @param events - the log to scan, in seq order.
|
|
* @returns the latest non-boundary event's `time`, or undefined when there is none.
|
|
*/
|
|
export function lastActivityTime(events: readonly SessionEvent[]): number | undefined {
|
|
return events.findLast(event => event.type !== 'session/end-seed')?.time
|
|
}
|
|
|
|
/** 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 their seqs to `sourceEventSeqs`.
|
|
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.message.content) {
|
|
if (block.type === 'tool-call') pendingCalls.set(block.id, { step: event.data.step })
|
|
}
|
|
break
|
|
case 'tool/call':
|
|
// Cite the `tool/call` seq from the synthetic result.
|
|
{
|
|
const entry = pendingCalls.get(event.data.callId)
|
|
if (entry) {
|
|
entry.callSeq = event.seq
|
|
}
|
|
}
|
|
break
|
|
case 'tool/result':
|
|
pendingCalls.delete(event.data.message.source.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
|
|
const message: ToolResultMessage = freezeMessage({
|
|
id: MessageId(`interrupted-tool-result-${callId}-${seq}`),
|
|
role: 'user',
|
|
source: { kind: 'tool', callId },
|
|
content: [{
|
|
type: 'tool-result',
|
|
toolCallId: callId,
|
|
isError: true,
|
|
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.',
|
|
}],
|
|
}],
|
|
})
|
|
closers.push({
|
|
type: 'tool/result',
|
|
seq: seq++,
|
|
time,
|
|
data: {
|
|
turn: openTurn,
|
|
step,
|
|
message,
|
|
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
|
|
}
|