| 1 | package checkpoint |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sort" |
| 6 | "sync" |
| 7 | "sync/atomic" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/diff" |
| 11 | ) |
| 12 | |
| 13 | // writerRegistry is shared across parent/child observers so background writer |
| 14 | // registration is race-free under a single mutex. |
| 15 | type writerRegistry struct { |
| 16 | mu sync.Mutex |
| 17 | writers map[string]ActiveWriter |
| 18 | barrierHeld map[string]bool |
| 19 | } |
| 20 | |
| 21 | // MutationObserver is the host-side unified file mutation observer that replaces |
| 22 | // a single onPreEdit hook. It captures preimages before mutations and after |
| 23 | // fingerprints regardless of tool success/failure. |
| 24 | // |
| 25 | // The observer is passed through Agent Options/context to sub-agents. It does |
| 26 | // not change provider-visible tool names, schemas, or system prompts. |
| 27 | type MutationObserver struct { |
| 28 | store *Store |
| 29 | |
| 30 | mu sync.Mutex |
| 31 | // shared writers registry (parent and clones share the same pointer). |
| 32 | reg *writerRegistry |
| 33 | // ownershipTurn is the turn that owns the current observation context. |
| 34 | // Foreground sub-agents inherit the parent turn; background ones keep the |
| 35 | // turn that spawned them. |
| 36 | ownershipTurn int |
| 37 | // writerID identifies the current agent for AfterMutation bookkeeping. |
| 38 | writerID string |
| 39 | // background marks a background sub-agent writer. |
| 40 | background bool |
| 41 | // seq is a monotonic mutation counter shared across the store session. |
| 42 | seq *atomic.Int64 |
| 43 | } |
| 44 | |
| 45 | // ObserverOptions configures a MutationObserver bound to a store. |
| 46 | type ObserverOptions struct { |
| 47 | Store *Store |
| 48 | OwnershipTurn int |
| 49 | WriterID string |
| 50 | Background bool |
| 51 | Seq *atomic.Int64 |
| 52 | } |
| 53 | |
| 54 | // NewMutationObserver binds an observer to store. A nil store yields a no-op observer. |
| 55 | func NewMutationObserver(opts ObserverOptions) *MutationObserver { |
| 56 | seq := opts.Seq |
| 57 | if seq == nil { |
| 58 | seq = &atomic.Int64{} |
| 59 | } |
| 60 | return &MutationObserver{ |
| 61 | store: opts.Store, |
| 62 | reg: &writerRegistry{ |
| 63 | writers: map[string]ActiveWriter{}, |
| 64 | barrierHeld: map[string]bool{}, |
| 65 | }, |
| 66 | ownershipTurn: opts.OwnershipTurn, |
| 67 | writerID: opts.WriterID, |
| 68 | background: opts.Background, |
| 69 | seq: seq, |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // CloneForSubagent returns a child observer that shares the store, mutation |
| 74 | // sequence, and writer registry but has its own writer identity and ownership turn. |
| 75 | func (o *MutationObserver) CloneForSubagent(writerID string, ownershipTurn int, background bool) *MutationObserver { |
| 76 | if o == nil { |
| 77 | return nil |
| 78 | } |
| 79 | return &MutationObserver{ |
| 80 | store: o.store, |
| 81 | reg: o.reg, |
| 82 | ownershipTurn: ownershipTurn, |
| 83 | writerID: writerID, |
| 84 | background: background, |
| 85 | seq: o.seq, |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // Store returns the underlying checkpoint store. |
| 90 | func (o *MutationObserver) Store() *Store { |
| 91 | if o == nil { |
| 92 | return nil |
| 93 | } |
| 94 | return o.store |
| 95 | } |
| 96 | |
| 97 | // SetOwnershipTurn updates the turn that owns subsequent captures. |
| 98 | func (o *MutationObserver) SetOwnershipTurn(turn int) { |
| 99 | if o == nil { |
| 100 | return |
| 101 | } |
| 102 | o.mu.Lock() |
| 103 | o.ownershipTurn = turn |
| 104 | o.mu.Unlock() |
| 105 | } |
| 106 | |
| 107 | // OwnershipTurn returns the current ownership turn. |
| 108 | func (o *MutationObserver) OwnershipTurn() int { |
| 109 | if o == nil { |
| 110 | return 0 |
| 111 | } |
| 112 | o.mu.Lock() |
| 113 | defer o.mu.Unlock() |
| 114 | return o.ownershipTurn |
| 115 | } |
| 116 | |
| 117 | // RegisterWriter marks a background writer as active. Rollback precheck returns |
| 118 | // busy while any writer is registered. |
| 119 | func (o *MutationObserver) RegisterWriter(id, kind string, turn int) error { |
| 120 | if o == nil || id == "" || o.reg == nil { |
| 121 | return nil |
| 122 | } |
| 123 | o.reg.mu.Lock() |
| 124 | defer o.reg.mu.Unlock() |
| 125 | if o.reg.writers == nil { |
| 126 | o.reg.writers = map[string]ActiveWriter{} |
| 127 | } |
| 128 | if o.reg.barrierHeld == nil { |
| 129 | o.reg.barrierHeld = map[string]bool{} |
| 130 | } |
| 131 | if o.reg.barrierHeld[id] { |
| 132 | return nil |
| 133 | } |
| 134 | o.reg.writers[id] = ActiveWriter{ID: id, Turn: turn, StartedAt: time.Now(), Kind: kind} |
| 135 | snap := o.snapshotWritersLocked() |
| 136 | if o.store != nil { |
| 137 | o.store.SetActiveWriters(snap) |
| 138 | if err := o.store.Barrier().EnterWrite(); err != nil { |
| 139 | delete(o.reg.writers, id) |
| 140 | o.store.SetActiveWriters(o.snapshotWritersLocked()) |
| 141 | return fmt.Errorf("register background writer: %w", err) |
| 142 | } |
| 143 | } |
| 144 | o.reg.barrierHeld[id] = true |
| 145 | return nil |
| 146 | } |
| 147 | |
| 148 | // UnregisterWriter removes a background writer. |
| 149 | func (o *MutationObserver) UnregisterWriter(id string) { |
| 150 | if o == nil || id == "" || o.reg == nil { |
| 151 | return |
| 152 | } |
| 153 | o.reg.mu.Lock() |
| 154 | defer o.reg.mu.Unlock() |
| 155 | delete(o.reg.writers, id) |
| 156 | snap := o.snapshotWritersLocked() |
| 157 | if o.store != nil { |
| 158 | o.store.SetActiveWriters(snap) |
| 159 | if o.reg.barrierHeld[id] { |
| 160 | o.store.Barrier().ExitWrite() |
| 161 | } |
| 162 | } |
| 163 | delete(o.reg.barrierHeld, id) |
| 164 | } |
| 165 | |
| 166 | // ActiveWriters returns a copy of currently registered writers. |
| 167 | func (o *MutationObserver) ActiveWriters() []ActiveWriter { |
| 168 | if o == nil || o.reg == nil { |
| 169 | return nil |
| 170 | } |
| 171 | o.reg.mu.Lock() |
| 172 | defer o.reg.mu.Unlock() |
| 173 | return o.snapshotWritersLocked() |
| 174 | } |
| 175 | |
| 176 | // Caller must hold o.reg.mu. |
| 177 | func (o *MutationObserver) snapshotWritersLocked() []ActiveWriter { |
| 178 | if o.reg == nil || len(o.reg.writers) == 0 { |
| 179 | return nil |
| 180 | } |
| 181 | out := make([]ActiveWriter, 0, len(o.reg.writers)) |
| 182 | for _, w := range o.reg.writers { |
| 183 | out = append(out, w) |
| 184 | } |
| 185 | sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) |
| 186 | return out |
| 187 | } |
| 188 | |
| 189 | // HasActiveWriters reports whether any background writer is still running. |
| 190 | func (o *MutationObserver) HasActiveWriters() bool { |
| 191 | return len(o.ActiveWriters()) > 0 |
| 192 | } |
| 193 | |
| 194 | // BeforeMutation captures the preimage for a known path before a tool or hook runs. |
| 195 | // Prefer this over the legacy Snapshot(diff.Change) path for built-in tools. |
| 196 | func (o *MutationObserver) BeforeMutation(path, tool string, source CaptureSource) { |
| 197 | if o == nil || o.store == nil || path == "" { |
| 198 | return |
| 199 | } |
| 200 | if source == "" { |
| 201 | source = CaptureBeforeMutation |
| 202 | } |
| 203 | o.store.CaptureBefore(path, CaptureBeforeOpts{ |
| 204 | Tool: tool, |
| 205 | Source: source, |
| 206 | WriterID: o.writerID, |
| 207 | OwnershipTurn: o.OwnershipTurn(), |
| 208 | Background: o.background, |
| 209 | }) |
| 210 | } |
| 211 | |
| 212 | // BeforeMutationFromChange is the Previewer-compatible path: uses OldText when |
| 213 | // provided for encoding-stable text captures, otherwise falls back to disk. |
| 214 | func (o *MutationObserver) BeforeMutationFromChange(ch diff.Change, tool string) { |
| 215 | if o == nil || o.store == nil || ch.Path == "" { |
| 216 | return |
| 217 | } |
| 218 | o.store.CaptureBeforeFromChange(ch, CaptureBeforeOpts{ |
| 219 | Tool: tool, |
| 220 | Source: CapturePreviewer, |
| 221 | WriterID: o.writerID, |
| 222 | OwnershipTurn: o.OwnershipTurn(), |
| 223 | Background: o.background, |
| 224 | }) |
| 225 | } |
| 226 | |
| 227 | // AfterMutation re-reads the path after a tool attempt (success or failure) and |
| 228 | // records the after fingerprint under Reasonix ownership. |
| 229 | func (o *MutationObserver) AfterMutation(path, tool string) { |
| 230 | if o == nil || o.store == nil || path == "" { |
| 231 | return |
| 232 | } |
| 233 | seq := o.seq.Add(1) |
| 234 | o.store.CaptureAfter(path, CaptureAfterOpts{ |
| 235 | Seq: seq, |
| 236 | Tool: tool, |
| 237 | Source: CaptureAfterMutation, |
| 238 | WriterID: o.writerID, |
| 239 | OwnershipTurn: o.OwnershipTurn(), |
| 240 | Background: o.background, |
| 241 | }) |
| 242 | } |
| 243 | |
| 244 | // RecordGap attaches an explicit coverage gap (bash, hook, MCP, …). |
| 245 | func (o *MutationObserver) RecordGap(gap CoverageGap) { |
| 246 | if o == nil || o.store == nil { |
| 247 | return |
| 248 | } |
| 249 | o.store.RecordGap(gap) |
| 250 | } |
| 251 | |
| 252 | // NoteCrossTurnBackgroundWriter records a gap when a new user turn begins while |
| 253 | // a background writer from an earlier turn is still active. |
| 254 | func (o *MutationObserver) NoteCrossTurnBackgroundWriter(newTurn int) { |
| 255 | if o == nil { |
| 256 | return |
| 257 | } |
| 258 | for _, w := range o.ActiveWriters() { |
| 259 | if w.Turn < newTurn { |
| 260 | o.RecordGap(CoverageGap{ |
| 261 | Reason: GapBackgroundWriter, |
| 262 | Detail: "background writer from earlier turn still active", |
| 263 | Tool: w.Kind, |
| 264 | }) |
| 265 | } |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | // CaptureBeforeOpts configures a preimage capture. |
| 270 | type CaptureBeforeOpts struct { |
| 271 | Tool string |
| 272 | Source CaptureSource |
| 273 | WriterID string |
| 274 | OwnershipTurn int |
| 275 | Background bool |
| 276 | } |
| 277 | |
| 278 | // CaptureAfterOpts configures an after-fingerprint capture. |
| 279 | type CaptureAfterOpts struct { |
| 280 | Seq int64 |
| 281 | Tool string |
| 282 | Source CaptureSource |
| 283 | WriterID string |
| 284 | OwnershipTurn int |
| 285 | Background bool |
| 286 | } |
| 287 |