fix(session): synthesize tool results for interrupted tool calls on crash recovery (review #33)

The loop logs the assistant/message (carrying tool-call blocks) BEFORE running
the tools, so a crash mid-tool leaves durable tool calls with no matching
tool/result. interruptedTurnClosers only added step/end + turn/end, so a resumed
session's deriveMessages() replayed a dangling assistant tool-call — which every
provider rejects as an invalid transcript on the next request.

interruptedTurnClosers now scans the interrupted turn for tool-call blocks
without a matching tool/result and synthesizes an error tool/result for each
(before the step/end), so the rehydrated history is a valid transcript. Adds a
dedicated repair.spec.ts and a shared-contract case proving both backends pair
every orphaned call with a result. Docs (ADR 0018, both persistence READMEs,
load() JSDoc) updated.

Also fixes the echo-agent README session-cleanup path: demo:echo runs from the
repo root, so sessions land in <repo-root>/.sessions/_no-cwd/, not
examples/echo-agent/.sessions/ (review #33).
This commit is contained in:
Tianyi Cui
2026-06-16 23:50:52 +08:00
parent 16e75c2631
commit 2bae18f811
8 changed files with 244 additions and 16 deletions

View File

@@ -90,14 +90,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[] }>