fix(session): a torn write no longer destroys the whole conversation - #18
Merged
Conversation
Phase 3 (data integrity & persistence, 5 files, 1,779 LOC). One real data-loss
path; the rest of the phase checked out clean and is recorded as cleared rather
than changed.
## An interrupted append lost the entire session
`Session::append` writes JSONL and never fsynced, so a turn was reported as
saved while the bytes could still be in the page cache. `load_messages` then
parsed with `.collect::<Result<_>>()`, meaning **any** malformed line failed the
whole load.
Together those are a total-loss path: crash or power-cut mid-append leaves a
half-written final line, and the next `/resume` fails outright — losing the
entire conversation, which is the one thing sessions exist to prevent.
Two changes:
- `append` now fsyncs, so a completed turn is durable rather than merely
buffered.
- `load_messages` tolerates a torn *final* line: it is dropped with a warning
and the rest of the history loads. Losing one turn beats losing all of them.
Corruption anywhere **other** than the last line is still a hard error, and
deliberately so. Silently skipping a middle line could drop a `tool_use` while
keeping its `tool_result`, which the API rejects outright — a subtly broken
conversation is worse than a clear error, and the message now names the line.
The parsing moved into `parse_message_lines` so this behaviour is testable
against an explicit file rather than the global sessions directory.
## Session export was not atomic
`export` used a direct `fs::write`, which truncates first — an interrupted
export left the user with an empty file where their transcript was. Now uses the
same `atomic_write` as the session files themselves.
## Checked and cleared, not changed
- **Compaction cannot break tool_use/tool_result pairing.** `snip_compact`
never removes blocks; it replaces `ToolResult` *content* with a placeholder
and leaves structure intact. This was the sharpest risk on the phase plan —
a mismatch is a hard API 400 that bricks a session — and the design is
already correct.
- **memory.rs durability is SQLite's.** Not a hand-rolled write path.
- **memory.rs SQL is fully parameterised**, `LIMIT` included. No injection.
- **`SessionMeta::save` and `overwrite` already use atomic_write** from an
earlier audit; only `append` and `export` had been missed.
QA (triple-checked): 606 tests, 0 failures. Clippy clean under the CI gate.
Release 19.08 MB. Zero panics in production code across all five Phase 3 files.
Phase 1 and 2 suites re-run green. The torn-line fix verified by restoring the
intolerant `collect()`, which fails 2 tests.
Co-Authored-By: Arch Linux <noreply@archlinux.org>
ForkedInTime
added a commit
that referenced
this pull request
Aug 4, 2026
… usable (#19) Phase 4. One defect, in the interaction between two individually-correct behaviours. The API rejects an assistant tool_use that no following user turn answers, and nothing validated history before sending. PR #18's torn-line recovery drops the final line — if that line was the tool_result, recovery leaves a dangling tool_use and every subsequent request 400s with no way back. Phase 3 turned total loss into permanent breakage. repair_dangling_tool_uses now synthesises the missing results on load, splicing into the existing user turn where there is one and stubbing only unanswered ids. Cleared without change: the live tool loop cannot produce an unanswered tool_use (every path pushes a ToolResult first); cancellation is safe (persistence happens only on turn completion). Still open: no 429 retry/backoff — 529 is handled, rate limiting is not. 611 tests, 0 failures. Clippy clean. Phase 1-3 suites re-run green.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 3 (data integrity & persistence, 5 files, 1,779 LOC). One real data-loss path; the rest of the phase checked out clean and is recorded as cleared rather than changed.
An interrupted append lost the entire session
Session::appendwrites JSONL and never fsynced, so a turn was reported as saved while the bytes could still be in the page cache.load_messagesthen parsed with.collect::<Result<_>>(), meaning any malformed line failed the whole load.Together those are a total-loss path: a crash or power-cut mid-append leaves a half-written final line, and the next
/resumefails outright — losing the entire conversation, which is the one thing sessions exist to prevent.appendnow fsyncs, so a completed turn is durable rather than merely buffered.load_messagestolerates a torn final line — dropped with a warning, the rest loads. Losing one turn beats losing all of them.Corruption anywhere other than the last line remains a hard error, deliberately. Silently skipping a middle line could drop a
tool_usewhile keeping itstool_result, which the API rejects outright — a subtly broken conversation is worse than a clear error. The message now names the offending line.Parsing moved into
parse_message_linesso the behaviour is testable against an explicit file rather than the global sessions directory.Session export was not atomic
exportused a directfs::write, which truncates first — an interrupted export left an empty file where the transcript was. Now uses the sameatomic_writeas the session files.Checked and cleared, not changed
snip_compactnever removes blocks — it replacesToolResultcontent with a placeholder and leaves structure intact. This was the sharpest risk on the phase plan (a mismatch is a hard API 400 that bricks a session) and the design is already correct.memory.rsdurability is SQLite's, not a hand-rolled write path.memory.rsSQL is fully parameterised,LIMITincluded. No injection.SessionMeta::saveandoverwritealready useatomic_writefrom an earlier audit — onlyappendandexporthad been missed.QA (triple-checked)
The torn-line fix verified by restoring the intolerant
collect(), which fails 2 tests.