fix(#147): repair torn trailing line so guard reload can't brick claude --resume#148
Merged
Conversation
added 2 commits
June 24, 2026 12:44
… brick resume (#147) Root cause of #147 "Failed to resume session": a guard terminate-first reload SIGTERMs Claude; if Claude was mid-append, it leaves a torn/partial last JSON line. The deferred writer then snapshot-conflicts (file changed) and — per #106 — skips the write, leaving Claude's file in place. Nothing repaired the torn line, so `claude --resume` could not parse the transcript. - session.repair_torn_trailing_line(): atomically drop a single torn trailing line (data the writer never finished — already unrecoverable; dropping it strictly improves resume). Conservative: no-op if the last line is valid; won't blank a file whose only line is torn; keeps a .torn.bak; never raises. - guard: call it on the deferred-write conflict/oserror paths (the cases that leave Claude's file), right before the resume the guard triggers. - doctor: new `unresumable-session` check + `--fix` so anyone who already hit this recovers in one command. 10 tests (helper edge cases, doctor check/fix, end-to-end guard conflict path; mutation-verified). Full suite 1755 passing; ledger/receipts untouched.
Fleet review (2 confirmed, both low): - cover the guard OSError repair branch (only the conflict branch was tested); asserts the torn line is repaired and the deferred writer doesn't raise. - receipts: create receipt files 0o600 and the receipts dir 0o700. They're de-identified (hashed ids) but still local provenance — keep them user-only so another local user on a shared host can't read them. Behavior-neutral for the single-user default. Core repair logic passed clean (data-safety / guard-cycle-safety / edge dims found nothing). Full suite 1757 passing.
There was a problem hiding this comment.
Pull request overview
This PR addresses #147 by making Claude Code sessions resilient to a terminate-first guard reload that can leave a torn (partial) trailing JSONL line, which breaks claude --resume. It adds a targeted repair helper, wires it into guard/doctor flows, and hardens receipt file permissions.
Changes:
- Add
session.repair_torn_trailing_line()to drop a single unparseable trailing JSONL line (best-effort, atomic, backup kept). - Invoke the repair on guard deferred-write skip/failure paths and add a
doctorcheck/fix for already-corrupted sessions. - Harden receipts storage permissions and add tests covering the new behaviors.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_resume_torn_line.py | New tests for torn-line repair helper, doctor check/fix, and guard deferred-write skip paths. |
| tests/test_receipts.py | Adds assertions that receipts dir and files are user-only permissioned. |
| src/cozempic/session.py | Introduces repair_torn_trailing_line() to safely drop a torn trailing JSONL line. |
| src/cozempic/receipts.py | Tightens receipt file mode and receipts directory permissions. |
| src/cozempic/guard.py | Calls torn-line repair after deferred prune write is skipped or fails, before triggering resume. |
| src/cozempic/doctor.py | Adds unresumable-session check and --fix repair for torn trailing lines. |
Comments suppressed due to low confidence (1)
src/cozempic/receipts.py:89
os.open(..., mode=0o600)only applies the mode when the file is newly created; if a receipt file already exists with broader permissions (e.g. from an older version), appends will keep it world/group-readable. Since this change is explicitly aiming for user-only receipts, explicitly chmod/fchmod the fd best-effort after opening so existing files are also hardened.
# 0o600: receipts are de-identified but still local provenance — keep them
# user-only so another local user on a shared host can't read them.
fd = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
try:
os.write(fd, data)
finally:
os.close(fd)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return False | ||
| if not raw: | ||
| return False | ||
| lines = raw.splitlines() |
| raw = path.read_text(encoding="utf-8", errors="surrogateescape") | ||
| except OSError: | ||
| return False | ||
| lines = raw.splitlines() |
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.
Fixes #147 — the highest-priority data-loss class.
Root cause
A guard terminate-first reload SIGTERMs Claude Code. If Claude was mid-append, it leaves a torn/partial trailing JSON line. The deferred writer then snapshot-conflicts (file changed) and — per the #106 invariant — skips the write, leaving Claude's file in place. Nothing repaired the torn line, so
claude --resumecould no longer parse the transcript: "Failed to resume session." cozempic's prune logic is sound (validation aborts a bad prune); it's the terminate that leaves the torn line.Fix
session.repair_torn_trailing_line()— atomically drops a single torn trailing line (data the writer never finished → already unrecoverable; dropping it strictly improves resumability). Conservative: no-op if the last line is valid JSON; won't blank a file whose only line is torn; keeps a.torn.bak; never raises.unresumable-sessioncheck +--fixso anyone who already hit this recovers in one command (cozempic doctor --fix).Safety
12 new tests (helper edge cases, doctor check/fix, end-to-end guard conflict and oserror paths; mutation-verified). Full suite 1757 passing.
Follow-up (separate, tracked):
cozempic uninstallto undoinit(also requested in #147).