返回 DeepSeek-Reasonix
snapshot.go
根目录 / internal / environment / snapshot.go
1 package environment
2
3 import (
4 "crypto/sha256"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "reasonix/internal/fileutil"
13 fileencoding "reasonix/internal/fileutil/encoding"
14 )
15
16 // Probe snapshots persist across process restarts so the environment section —
17 // which sits inside the provider-cached system-prompt prefix — stays
18 // byte-stable between rebuilds and relaunches. Live probes are point-in-time
19 // observations: a 2s timeout flips a slow tool between "found" and "timeout",
20 // a GUI-launched desktop resolves a different PATH than a login shell, a cold
21 // docker daemon reports an exit error. Re-observing on every rebuild rewrote
22 // the prefix and invalidated every session's provider cache (10x miss pricing)
23 // for no user-visible reason. Persisting one snapshot per probe fingerprint
24 // under the shared cache root keeps rebuilds — and the CLI and desktop on the
25 // same machine — on identical bytes until the snapshot ages out.
26 const probeSnapshotTTL = 24 * time.Hour
27
28 const probeSnapshotVersion = 1
29
30 type probeSnapshot struct {
31 Version int `json:"version"`
32 Fingerprint string `json:"fingerprint"`
33 StoredAt time.Time `json:"stored_at"`
34 Results []ProbeResult `json:"results"`
35 }
36
37 func probeSnapshotPath(dir, fingerprint string) string {
38 dir = strings.TrimSpace(dir)
39 if dir == "" {
40 return ""
41 }
42 sum := sha256.Sum256([]byte(fingerprint))
43 return filepath.Join(dir, "environment", fmt.Sprintf("probes-%x.json", sum[:8]))
44 }
45
46 // loadProbeSnapshot returns the persisted snapshot for fingerprint, however
47 // stale. Callers decide whether it is fresh enough to serve directly or only
48 // usable as the flap-merge reference.
49 func loadProbeSnapshot(dir, fingerprint string) (probeSnapshot, bool) {
50 path := probeSnapshotPath(dir, fingerprint)
51 if path == "" {
52 return probeSnapshot{}, false
53 }
54 b, err := fileencoding.ReadFileUTF8(path)
55 if err != nil {
56 return probeSnapshot{}, false
57 }
58 var snap probeSnapshot
59 if err := json.Unmarshal(b, &snap); err != nil {
60 return probeSnapshot{}, false
61 }
62 if snap.Version != probeSnapshotVersion || snap.Fingerprint != fingerprint {
63 return probeSnapshot{}, false
64 }
65 return snap, true
66 }
67
68 func saveProbeSnapshot(dir, fingerprint string, results []ProbeResult, now time.Time) {
69 path := probeSnapshotPath(dir, fingerprint)
70 if path == "" {
71 return
72 }
73 b, err := json.Marshal(probeSnapshot{
74 Version: probeSnapshotVersion,
75 Fingerprint: fingerprint,
76 StoredAt: now,
77 Results: results,
78 })
79 if err != nil {
80 return
81 }
82 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
83 return
84 }
85 tmp, err := os.CreateTemp(filepath.Dir(path), ".probes.*.tmp")
86 if err != nil {
87 return
88 }
89 tmpPath := tmp.Name()
90 if _, err := tmp.Write(b); err != nil {
91 tmp.Close()
92 os.Remove(tmpPath)
93 return
94 }
95 if err := tmp.Close(); err != nil {
96 os.Remove(tmpPath)
97 return
98 }
99 if err := fileutil.ReplaceFile(tmpPath, path); err != nil {
100 os.Remove(tmpPath)
101 }
102 }
103
104 // transientProbeFailure reports whether a probe result describes a failure
105 // that says nothing definitive about whether the tool exists: a timeout (slow
106 // first run, cold daemon) or a nonzero exit (daemon down, transient state).
107 // "not found" and "not trusted" are definitive — the binary is gone from PATH
108 // or rejected by policy — and must be adopted, not papered over.
109 func transientProbeFailure(r ProbeResult) bool {
110 if r.Found {
111 return false
112 }
113 return r.Error == "timeout" || strings.HasPrefix(r.Error, "exit ")
114 }
115
116 // mergeProbeSnapshot overlays fresh results onto the previous snapshot's:
117 // a fresh transient failure keeps the previous successful observation for the
118 // same probe, so a slow or flaky tool cannot flip the rendered environment
119 // section — and with it the whole cached prompt prefix — between rebuilds.
120 // Definitive results (found, not found, not trusted) always win.
121 func mergeProbeSnapshot(previous, fresh []ProbeResult) []ProbeResult {
122 if len(previous) == 0 {
123 return fresh
124 }
125 prevByCommand := make(map[string]ProbeResult, len(previous))
126 for _, r := range previous {
127 if r.Found {
128 prevByCommand[r.Command] = r
129 }
130 }
131 merged := append([]ProbeResult(nil), fresh...)
132 for i, r := range merged {
133 if !transientProbeFailure(r) {
134 continue
135 }
136 if prev, ok := prevByCommand[r.Command]; ok {
137 merged[i] = prev
138 }
139 }
140 sortResults(merged)
141 return merged
142 }
143
143 lines GO