Merge remote-tracking branch 'origin/split/agent-factory' into split/session-persistence-sqlite

# Conflicts:
#	docs/adr/0018-session-persistence.md
This commit is contained in:
Tianyi Cui
2026-06-16 23:53:54 +08:00
8 changed files with 244 additions and 16 deletions

View File

@@ -10,14 +10,14 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
|---|---|
| `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
| `append(id, events): Promise<void>` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
| `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. |
| `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. |
| `list(): Promise<SessionMeta[]>` | Lightweight listing from metadata, no full-log parse. |
| `has(id)` / `delete(id)` | Existence / removal. A zero-event lazily-materialized session is absent from `has`/`list`. |
| `update(id, summary): Promise<void>` | Update mutable `SessionSummary` fields without touching the append-only log. |
## Invariants every backend must honor
- **Append-only; a crashed turn is closed, not truncated.** Committed events (at or below a flushed `turn/end`) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (`step/end?`+`turn/end {interrupted}`) to balance the log. Only a never-fully-written torn tail fragment is discarded.
- **Append-only; a crashed turn is closed, not truncated.** Committed events (at or below a flushed `turn/end`) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded.
- **Contiguous seq.** `load` rejects a `seq` gap/parse error in the MIDDLE of the log; `append`'s first `seq` must equal the stored next-seq.
- **JSON-serializable data.** `append` rejects non-serializable `event.data`; backends snapshot each event when buffering (the live `session.events` object is mutable).
- **Durability.** `append` returns only once the batch is durable.

View File

@@ -93,14 +93,17 @@ export abstract class SessionPersistence extends Service {
* `turn/end`. Those events are PRESERVED — a single turn can be huge in a
* long-horizon task, so truncating it would destroy real work — and `load`
* CLOSES the orphaned turn by durably appending the minimal synthetic boundary
* events (a `step/end` if a step was open, then a `turn/end` carrying the
* `{ kind: 'interrupted' }` reason). The returned `events` therefore end on a
* balanced `turn/end` and are immediately usable as a session seed. Only a
* never-fully-written TORN tail fragment (a half-written final record) is
* discarded. Returned events are contiguous (`events[i].seq === i`); a parse
* error or a `seq` gap in the COMMITTED region (at or before the last real
* `turn/end`) makes the session unloadable (reject). Rejects an unknown format
* `version`. See ADR 0018 for the crash-recovery contract.
* events: an error `tool/result` for every `tool-call` the crash left
* unanswered (so the rehydrated history is a valid provider transcript — a
* dangling assistant tool-call is otherwise rejected), then a `step/end` if a
* step was open, then a `turn/end` carrying the `{ kind: 'interrupted' }`
* reason. The returned `events` therefore end on a balanced `turn/end` and are
* immediately usable as a session seed. Only a never-fully-written TORN tail
* fragment (a half-written final record) is discarded. Returned events are
* contiguous (`events[i].seq === i`); a parse error or a `seq` gap in the
* COMMITTED region (at or before the last real `turn/end`) makes the session
* unloadable (reject). Rejects an unknown format `version`. See ADR 0018 for
* the crash-recovery contract.
*/
abstract load(id: SessionId): Promise<{ meta: SessionMeta; events: SessionEvent[] }>

View File

@@ -11,6 +11,7 @@
import { describe, expect, it } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionMeta } from '@deepseek-ai/dsh-session'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { SessionPersistence } from '../src/index.ts'
/** A backend under test plus its teardown. */
@@ -102,6 +103,46 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
}
})
it('crash recovery: an interrupted tool call gets a synthetic error result so resume is a valid transcript', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('interrupted-toolcall')
await persistence.create(m)
await persistence.append(m.id, oneTurnLog()) // turn 1, committed (seqs 0..5)
// Turn 2 crashed AFTER the assistant message asked for a tool call but
// BEFORE the tool/result was written (the loop runs tools after logging
// the assistant message — a process killed mid-tool lands exactly here).
await persistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 8, time: 9, data: { turn: 2, step: 1, content: [
{ type: 'tool-call', id: CallId('call-x'), name: 'bash', arguments: '{}' },
] } },
])
const loaded = await persistence.load(m.id)
// The orphaned call is answered by a synthetic error tool/result BEFORE
// step/end + turn/end {interrupted}, so the step (and turn) are balanced
// and a resumed session derives a valid transcript (no dangling call).
expect(loaded.events.map(e => e.type)).toEqual([
'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1
'turn/start', 'step/start', 'assistant/message', 'tool/result', 'step/end', 'turn/end', // turn 2
])
const synthetic = loaded.events.find(e => e.type === 'tool/result')
expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({
callId: CallId('call-x'), isError: true, error: { code: 'interrupted' },
})
// The synthetic result carries the SAME callId as the orphaned tool-call,
// so deriveMessages() pairs them — no provider-invalid dangling call.
const call = loaded.events.findLast(e => e.type === 'assistant/message')
const callId = call?.type === 'assistant/message'
&& call.data.content.find(b => b.type === 'tool-call')
expect(callId && callId.type === 'tool-call' && callId.id).toBe(CallId('call-x'))
} finally {
await dispose()
}
})
it('has()/list() exclude a created-but-never-appended (zero-event) session', async () => {
const { persistence, dispose } = await make()
try {