Skip to content

refactor: append-only transcript persistence + fsync batching#19

Merged
yanmxa merged 2 commits into
mainfrom
trace/1-persistence
May 15, 2026
Merged

refactor: append-only transcript persistence + fsync batching#19
yanmxa merged 2 commits into
mainfrom
trace/1-persistence

Conversation

@yanmxa
Copy link
Copy Markdown
Member

@yanmxa yanmxa commented May 15, 2026

PR 1 of 5.

Replaces full-file rewrite (`FileStore.Replace`) with per-event append.

  • `Store.Save` → `Start` + per-node `AppendMessage` + one `PatchState`. Old `Replace` / `ReplaceCommand` / `recordsForTranscript` / `messageExistsLocked` deleted.
  • `AppendMessage` deduped via in-memory `persistedIDs` cache. Lazy scan once, O(1) thereafter.
  • `appendRecord` takes a `sync bool`. Critical writes (`message.appended`, `session.started`, `session.compacted`) sync; `state.patched` doesn't. Typical turn: ~5 fsyncs → 1.

Behavior preserved — all existing resume / fork / JSONL-integrity tests pass.

Test

  • `go build ./...` clean
  • `go test ./...` — 50 packages pass, 0 FAIL
  • New: `TestFileStoreAppendMessageIsIdempotent` covers dedup across fresh FileStore instances

🤖 Generated with Claude Code

Switches Store.Save from FileStore.Replace (full-file rewrite per turn)
to per-event append. The old path is removed.

  - Store.Save calls Start (idempotent) + AppendMessage per node +
    one PatchState. The Replace function, ReplaceCommand,
    recordsForTranscript, and messageExistsLocked all delete.

  - AppendMessage deduped via in-memory persistedIDs cache, populated
    lazily by scanning the file once. After that, O(1) dedup instead
    of O(N) per call. Delete clears the cache entry.

  - StateOpsFor exposes the same patch-list construction the old
    Replace path used. PatchTag/PatchMode/PatchWorktree exported so
    Save can build the op list outside the package.

  - appendRecord gains a sync bool parameter:
      sync=true: message.appended, session.started, session.compacted
      sync=false: state.patched
    Typical turn drops from ~5 fsync calls to 1.

Test changes: TestFileStoreReplace is gone; TestFileStoreAppendMessageIsIdempotent
exercises the new dedup path including a fresh FileStore re-scanning the file.
Two regressions exposed by switching Save from full-rewrite (Replace) to
append-only.

P1: stable message IDs
  ConvertToEntries generated a fresh UUID for every message on every call.
  Combined with the new dedup-by-message-ID path, this meant each save
  treated the entire history as new and re-appended it — transcript files
  grew quadratically and resume saw a tangled chain.

  Fix: ChatMessage gains an ID field, assigned once when a message enters
  conv.Messages via Append. ConvertToEntries prefers msg.ID and only
  falls back to generateShortID() when absent (for paths that bypass
  conv.Append).

  Test: Test_ConvertToEntries_preservesChatMessageID,
        TestSession_SaveTwice_NoDuplication

P2: unconditional state patches
  StateOpsFor skipped PatchTasks when state.Tasks was empty and PatchWorktree
  when state.Worktree was nil. Under Replace this was fine (the rewrite
  removed any prior patches); under append-only it leaves prior values
  in place — clearing tasks or exiting a worktree never reaches the
  projected state on reload.

  Fix: emit both ops unconditionally. PatchTasks(empty) serializes to "[]"
  and PatchWorktree(nil) to "null"; the projector already handles both.

  Test: TestSession_SaveClearedTasks_ClearsOnReload
@yanmxa
Copy link
Copy Markdown
Member Author

yanmxa commented May 15, 2026

Both issues fixed in c001ffe (force-pushed; stack rebased).

P1 — stable IDs at the source:

  • core.ChatMessage gains ID string
  • ConversationModel.Append stamps a fresh core.NewMessageID() on entry to conv.Messages (single chokepoint, all existing callers covered)
  • ConvertToEntries prefers msg.ID, falls back to generateShortID() only when absent
  • Tests: Test_ConvertToEntries_preservesChatMessageID, TestSession_SaveTwice_NoDuplication (verifies on-disk record count, not just projection — catches duplicate-history leaks projection would hide)

P2 — unconditional state patches:

  • Dropped both if len(state.Tasks) > 0 and if state.Worktree != nil guards in StateOpsFor (the worktree case wasn't called out but has the same shape)
  • PatchTasks(empty)"[]", PatchWorktree(nil)"null"; projector already handles both
  • Test: TestSession_SaveClearedTasks_ClearsOnReload

All 5 stack branches rebuilt + verified: build clean, tests green on each branch independently.

@yanmxa yanmxa merged commit c3c9962 into main May 15, 2026
1 check passed
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.

1 participant