Files
2026-07-13 13:00:08 +08:00

160 lines
5.0 KiB
Go

package control
import (
"fmt"
"sync"
"reasonix/internal/checkpoint"
"reasonix/internal/diff"
)
// checkpointManager owns the snapshot-based rewind bookkeeping: the per-session
// checkpoint store, the monotonic turn counter, and the conversation-rewind
// boundary map. Like approvalManager it holds only the bookkeeping behind its own
// lock, off the controller's c.mu — the Controller keeps the rewind/fork
// orchestration (truncating the session, restoring code, emitting events) that
// needs its other collaborators.
//
// turn is decoupled from the store so it never collides after a log restructure;
// bound[turn] records len(Session.Messages) at that turn's start — the truncation
// boundary for a conversation rewind/fork. Boundaries are persisted in each
// checkpoint and rebuilt from the store on resume (so a reopened session can still
// rewind conversation / fork), but dropped after a summarize restructures the log
// so those operations report "unavailable" rather than mis-truncating; code
// rewind (file-based) is unaffected. Every store call does its disk I/O off mu —
// mu is taken only to read/swap the store pointer and mutate turn/bound.
type checkpointManager struct {
// mu guards store, turn, and bound; every critical section under it is short
// and non-blocking (no disk I/O).
mu sync.Mutex
store *checkpoint.Store
turn int
bound map[int]int
}
// rebind points the store at the (possibly new) session, loading any checkpoints
// already on disk, and resets the turn counter and boundaries from them. root is
// the workspace root used to guard restore writes. Called on construction and
// whenever the session path changes (NewSession/Resume/SetSessionPath/fork).
func (m *checkpointManager) rebind(dir, root string) {
store := checkpoint.New(dir, root)
next := store.NextTurn() // continue numbering past any checkpoints on disk
bound := store.Bounds() // rebuilt from persisted checkpoints so a resumed
if bound == nil { // session can still rewind conversation / fork
bound = map[int]int{}
}
m.mu.Lock()
m.store = store
m.turn = next
m.bound = bound
m.mu.Unlock()
}
// enabled reports whether a checkpoint store is bound.
func (m *checkpointManager) enabled() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.store != nil
}
// begin opens a checkpoint for the turn about to run, recording msgIndex as the
// conversation-rewind boundary. No-op when checkpoints are disabled.
func (m *checkpointManager) begin(input string, msgIndex int) {
m.mu.Lock()
store := m.store
if store == nil {
m.mu.Unlock()
return
}
turn := m.turn
m.turn++
m.bound[turn] = msgIndex
m.mu.Unlock()
store.Begin(turn, input, msgIndex)
}
// turnsByMessageIndex returns message-log index -> checkpoint turn over live
// boundaries. The desktop transcript uses this authoritative map instead of
// recounting visible user bubbles, which can diverge when synthetic user-role
// messages are hidden from the UI.
func (m *checkpointManager) turnsByMessageIndex() map[int]int {
m.mu.Lock()
defer m.mu.Unlock()
out := make(map[int]int, len(m.bound))
for turn, index := range m.bound {
if existing, ok := out[index]; ok && existing < turn {
continue
}
out[index] = turn
}
return out
}
// boundary returns the recorded turn-start message index, if any.
func (m *checkpointManager) boundary(turn int) (int, bool) {
m.mu.Lock()
defer m.mu.Unlock()
b, ok := m.bound[turn]
return b, ok
}
// list returns the checkpoint metadata (nil when disabled).
func (m *checkpointManager) list() []checkpoint.Meta {
m.mu.Lock()
store := m.store
m.mu.Unlock()
if store == nil {
return nil
}
return store.List()
}
// restoreCode reverts every file changed at or after turn to its pre-turn
// content. Errors when checkpoints are disabled.
func (m *checkpointManager) restoreCode(turn int) (written, deleted []string, err error) {
m.mu.Lock()
store := m.store
m.mu.Unlock()
if store == nil {
return nil, nil, fmt.Errorf("checkpoints unavailable")
}
return store.RestoreCode(turn)
}
// snapshot records a pre-edit file change into the open checkpoint — the
// executor's pre-edit hook. No-op when disabled.
func (m *checkpointManager) snapshot(ch diff.Change) {
m.mu.Lock()
store := m.store
m.mu.Unlock()
if store != nil {
store.Snapshot(ch)
}
}
// truncateFrom renumbers future turns from `turn` and drops every boundary at or
// after it — the conversation-rewind renumber after the message log is cut back.
func (m *checkpointManager) truncateFrom(turn int) {
m.mu.Lock()
store := m.store
m.turn = turn
for k := range m.bound {
if k >= turn {
delete(m.bound, k)
}
}
m.mu.Unlock()
if store != nil {
store.TruncateFrom(turn)
}
}
// clearBounds drops every boundary after a summarize restructures the log (so
// conversation rewind degrades to "unavailable" until fresh turns rebuild them)
// while keeping turn monotonic so new turns don't collide with the store.
func (m *checkpointManager) clearBounds() {
m.mu.Lock()
m.bound = map[int]int{}
m.mu.Unlock()
}