| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "fmt" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/internal/provider" |
| 10 | ) |
| 11 | |
| 12 | // ownsWritableBaseline reports whether this session may rewrite path at the |
| 13 | // current disk revision. Ownership requires either a digest match against the |
| 14 | // session's persisted baseline, or a live generation-bound write authority for |
| 15 | // the path at the same revision. Process-level "I hold a lease" alone is not |
| 16 | // enough: a stale controller after rebind must not rewrite under a successor. |
| 17 | func (s *Session) ownsWritableBaseline(path string, existingDigest, rawDigest [sha256.Size]byte, rawDiffers bool, existingRevision int64, existingLedgerDigest string, nextVersion uint64) bool { |
| 18 | if s.ownsPersistedState(path, existingDigest, existingRevision, existingLedgerDigest, nextVersion) { |
| 19 | return true |
| 20 | } |
| 21 | if rawDiffers && s.ownsPersistedState(path, rawDigest, existingRevision, existingLedgerDigest, nextVersion) { |
| 22 | return true |
| 23 | } |
| 24 | state := s.persistState(path) |
| 25 | if !state.ok || !state.revisionKnown || state.version > nextVersion { |
| 26 | return false |
| 27 | } |
| 28 | if existingRevision != 0 && state.revision != existingRevision { |
| 29 | return false |
| 30 | } |
| 31 | // Authority-bound same-revision reshape (tool preview, load normalize). |
| 32 | return s.hasValidWriteAuthority(path) |
| 33 | } |
| 34 | |
| 35 | // fixedWriterRecoverySessionPath is the process-wide stable recovery path |
| 36 | // for originalPath. Nested -recovery- names peel back to the root branch. |
| 37 | func fixedWriterRecoverySessionPath(originalPath string) string { |
| 38 | return stableRecoverySessionPath(originalPath, SessionWriterID()) |
| 39 | } |
| 40 | |
| 41 | func recoveryRootID(path string) string { |
| 42 | if root, ok := RecoveryFilenameRootID(path); ok { |
| 43 | return root |
| 44 | } |
| 45 | id := strings.TrimSpace(BranchID(path)) |
| 46 | if id == "" { |
| 47 | return "session" |
| 48 | } |
| 49 | return id |
| 50 | } |
| 51 | |
| 52 | // stableRecoverySessionPath is one recovery file per (root branch, generation). |
| 53 | // The 16-hex suffix stays so older desktop discovery still recognizes the file. |
| 54 | func stableRecoverySessionPath(originalPath, generation string) string { |
| 55 | root := recoveryRootID(originalPath) |
| 56 | if strings.TrimSpace(generation) == "" { |
| 57 | generation = SessionWriterID() |
| 58 | } |
| 59 | sum := sha256.Sum256([]byte(root + "\x00" + generation)) |
| 60 | suffix := fmt.Sprintf("-recovery-%x", sum[:8]) |
| 61 | stem := recoveryParentStem(root) |
| 62 | if stem+suffix == BranchID(originalPath) { |
| 63 | return originalPath |
| 64 | } |
| 65 | return filepath.Join(filepath.Dir(originalPath), stem+suffix+".jsonl") |
| 66 | } |
| 67 | |
| 68 | func (s *Session) recoveryGenerationKey() string { |
| 69 | if s == nil { |
| 70 | return SessionWriterID() |
| 71 | } |
| 72 | s.mu.Lock() |
| 73 | if s.recoveryLane != "" { |
| 74 | lane := s.recoveryLane |
| 75 | s.mu.Unlock() |
| 76 | return lane |
| 77 | } |
| 78 | s.mu.Unlock() |
| 79 | candidate := "" |
| 80 | if auth := s.WriteAuthority(); auth != nil && auth.Generation() != 0 { |
| 81 | writerID := SessionWriterID() |
| 82 | if writer := auth.Writer(); writer != nil && strings.TrimSpace(writer.WriterID()) != "" { |
| 83 | writerID = writer.WriterID() |
| 84 | } |
| 85 | candidate = fmt.Sprintf("%s\x00gen-%d", writerID, auth.Generation()) |
| 86 | } |
| 87 | s.mu.Lock() |
| 88 | defer s.mu.Unlock() |
| 89 | if s.recoveryLane == "" { |
| 90 | if candidate == "" { |
| 91 | candidate = newSessionWriterID() |
| 92 | } |
| 93 | s.recoveryLane = candidate |
| 94 | } |
| 95 | return s.recoveryLane |
| 96 | } |
| 97 | |
| 98 | func (s *Session) isolatedRecoverySessionPath(originalPath string) (string, string) { |
| 99 | gen := s.recoveryGenerationKey() |
| 100 | return stableRecoverySessionPath(originalPath, gen), gen |
| 101 | } |
| 102 | |
| 103 | func (s *Session) rotateRecoveryLane(current string) { |
| 104 | s.mu.Lock() |
| 105 | defer s.mu.Unlock() |
| 106 | if s.recoveryLane == "" || s.recoveryLane == current { |
| 107 | s.recoveryLane = newSessionWriterID() |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | // writeRecoveryEventLog writes a recovery event log. Isolated writer lanes |
| 112 | // compact so repeated in-place rewrites stay bounded. |
| 113 | |
| 114 | func writeRecoveryEventLog(path string, msgs []provider.Message, digest [sha256.Size]byte, revision int64, isolated bool) error { |
| 115 | baseRevision := max(int64(0), revision-1) |
| 116 | if isolated { |
| 117 | return compactSessionEventLog(path, msgs, digest, baseRevision, "recovery") |
| 118 | } |
| 119 | return appendSessionReplaceEvent(path, msgs, digest, baseRevision, "recovery") |
| 120 | } |
| 121 |