Skip to content

fix(#147): repair torn trailing line so guard reload can't brick claude --resume#148

Merged
junaidtitan merged 2 commits into
mainfrom
fix/resume-torn-line
Jun 24, 2026
Merged

fix(#147): repair torn trailing line so guard reload can't brick claude --resume#148
junaidtitan merged 2 commits into
mainfrom
fix/resume-torn-line

Conversation

@junaidtitan

Copy link
Copy Markdown
Contributor

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 --resume could 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.
  • Guard calls it on the deferred-write conflict/oserror branches (the cases that leave Claude's file), before the resume it triggers.
  • Doctor new unresumable-session check + --fix so 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 uninstall to undo init (also requested in #147).

Ruya 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.
Copilot AI review requested due to automatic review settings June 24, 2026 08:54
@junaidtitan
junaidtitan merged commit cd96f05 into main Jun 24, 2026
1 check passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 doctor check/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.

Comment thread src/cozempic/session.py
return False
if not raw:
return False
lines = raw.splitlines()
Comment thread src/cozempic/doctor.py
raw = path.read_text(encoding="utf-8", errors="surrogateescape")
except OSError:
return False
lines = raw.splitlines()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

"Can't resume session"?

2 participants