160 lines
5.0 KiB
Go
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()
|
|
}
|