返回 DeepSeek-Reasonix
snapshot_conflict_diagnostic.go
根目录 / internal / control / snapshot_conflict_diagnostic.go
1 package control
2
3 import (
4 "encoding/json"
5 "errors"
6 "os"
7 "path/filepath"
8 "strconv"
9 "strings"
10 "sync"
11 "sync/atomic"
12 "time"
13
14 "reasonix/internal/agent"
15 "reasonix/internal/store"
16 )
17
18 type snapshotConflictDiagnostic struct {
19 At time.Time `json:"at"`
20 BranchID string `json:"branch_id"`
21 Mode string `json:"mode"`
22 Outcome string `json:"outcome"`
23 Kind string `json:"kind,omitempty"`
24 DiskMessages int `json:"disk_messages,omitempty"`
25 SnapshotMessages int `json:"snapshot_messages,omitempty"`
26 BaseRevision int64 `json:"base_revision,omitempty"`
27 DiskRevision int64 `json:"disk_revision,omitempty"`
28 RecoveryBranchID string `json:"recovery_branch_id,omitempty"`
29 ExistingRecovery bool `json:"existing_recovery,omitempty"`
30 Occurrence int `json:"occurrence,omitempty"`
31 Repeated bool `json:"repeated_in_process,omitempty"`
32 }
33
34 // conflictDiagDedup bounds repeated conflict event log lines for the same
35 // {path, disk revision} key within a process. Physical recovery outcomes keep
36 // the first repeat so doctor can observe the concurrent-writer signal.
37 var conflictDiagDedup sync.Map // key -> *atomic.Int64
38
39 // conflictDiagOccurrences counts recovery/conflict outcomes by logical topic
40 // for this process. Only the count is persisted; the topic ID is never written
41 // to the diagnostic record.
42 var conflictDiagOccurrences sync.Map // logical topic key -> *atomic.Int64
43
44 // RecordRecoveryLifecycle appends one content-free catalog or cleanup outcome
45 // to the existing per-session recovery ledger. The closed outcome set prevents
46 // callers from persisting user-controlled text as diagnostic metadata.
47 func RecordRecoveryLifecycle(path, outcome string) {
48 mode := ""
49 switch outcome {
50 case "classified_covered", "classified_adopted", "classified_preferred", "classified_diverged":
51 mode = "catalog"
52 case "cleanup_moved", "cleanup_kept", "cleanup_skipped_in_use", "cleanup_revalidation_failed":
53 mode = "cleanup"
54 default:
55 return
56 }
57 appendSnapshotConflictDiagnostic(path, mode, outcome, nil, "", false)
58 }
59
60 func appendSnapshotConflictDiagnostic(path, mode, outcome string, saveErr error, recoveryPath string, existing bool) {
61 path = strings.TrimSpace(path)
62 if path == "" {
63 return
64 }
65 var diskRev int64
66 var conflict *agent.SessionSnapshotConflictError
67 if errors.As(saveErr, &conflict) && conflict != nil {
68 diskRev = conflict.DiskRevision
69 }
70 rec := snapshotConflictDiagnostic{
71 At: time.Now(),
72 BranchID: agent.BranchID(path),
73 Mode: mode,
74 Outcome: outcome,
75 }
76 createsPhysicalRecovery := diagnosticCreatesPhysicalRecovery(outcome)
77 if createsPhysicalRecovery {
78 logicalKey := rec.BranchID
79 if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && strings.TrimSpace(meta.TopicID) != "" {
80 logicalKey = strings.Join([]string{meta.Scope, meta.WorkspaceRoot, meta.TopicID}, "\x00")
81 }
82 value, _ := conflictDiagOccurrences.LoadOrStore(logicalKey, &atomic.Int64{})
83 occurrence := int(value.(*atomic.Int64).Add(1))
84 rec.Occurrence = occurrence
85 rec.Repeated = occurrence > 1
86 }
87 dedupKey := path + "\x00" + outcome + "\x00" + strconv.FormatInt(diskRev, 10)
88 dedupValue, _ := conflictDiagDedup.LoadOrStore(dedupKey, &atomic.Int64{})
89 dedupOccurrence := dedupValue.(*atomic.Int64).Add(1)
90 dedupLimit := int64(1)
91 if createsPhysicalRecovery {
92 dedupLimit = 2
93 }
94 if dedupOccurrence > dedupLimit {
95 return
96 }
97 if conflict != nil {
98 rec.Kind = string(conflict.Kind)
99 rec.DiskMessages = conflict.ExistingMessages
100 rec.SnapshotMessages = conflict.SnapshotMessages
101 rec.BaseRevision = conflict.BaseRevision
102 rec.DiskRevision = conflict.DiskRevision
103 }
104 if recoveryPath != "" {
105 rec.RecoveryBranchID = agent.BranchID(recoveryPath)
106 rec.ExistingRecovery = existing
107 }
108 data, err := json.Marshal(rec)
109 if err != nil {
110 return
111 }
112 logPath := store.SessionConflictLog(path)
113 if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
114 return
115 }
116 f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
117 if err != nil {
118 return
119 }
120 defer f.Close()
121 _, _ = f.Write(append(data, '\n'))
122 }
123
124 func diagnosticCreatesPhysicalRecovery(outcome string) bool {
125 switch strings.TrimSpace(outcome) {
126 case "moved_to_stable_recovery", "forked_recovery_branch", "forked_file_lock_recovery":
127 return true
128 default:
129 return false
130 }
131 }
132
132 lines GO