Skip to content

Commit fe82cb3

Browse files
authored
fix(engine): scope staleness checks to the loaded graph (#158)
Staleness took both the age signal and the per-repo VCS check from the machine-wide graph receipt, which every server process on the machine rewrites and which therefore need not describe the graph this server holds. It reported drift for repos absent from the graph, and missed drift in the loaded ones. Receipt entries are now filtered to repos present in the loaded graph, and the age comes from the loaded snapshot's own generated_at, with the receipt as a fallback only when it describes a loaded repo. Fact output is unchanged; no cacheVersion bump.
1 parent 61bcc71 commit fe82cb3

2 files changed

Lines changed: 275 additions & 19 deletions

File tree

internal/engine/freshness.go

Lines changed: 79 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,32 +30,43 @@ func (s Staleness) Stale() bool { return s.TooOld || len(s.Changed) > 0 }
3030
// CURRENT VCS state, without regenerating anything. A repo counts as changed when
3131
// its git HEAD has moved since the snapshot, or its working tree is NEWLY dirty
3232
// (a tree that was already dirty at snapshot time is ignored — that state was
33-
// captured by the extractors). Age is measured from the graph-wide generated_at in
34-
// ~/.enola/receipt.json, falling back to the in-memory snapshot meta for a
35-
// single-repo graph. Repos without git contribute only to the age signal.
33+
// captured by the extractors). Repos without git contribute only to the age signal.
34+
//
35+
// Everything it judges is scoped to the repos actually in the loaded graph. Age comes
36+
// from the loaded snapshot's own generated_at; the machine-wide
37+
// ~/.enola/receipt.json is consulted only as a fallback, and only when it describes a
38+
// loaded repo. That scoping is load-bearing, not defensive: the receipt is shared by
39+
// every enola process on the machine, so a sibling agent terminal's snapshot routinely
40+
// makes it describe a different graph entirely.
3641
func (e *Engine) Staleness(maxAge time.Duration, now time.Time) Staleness {
3742
var st Staleness
3843

3944
gr, err := LoadGlobalReceipt()
40-
if err == nil && gr.GeneratedAt != "" {
41-
if t, perr := time.Parse(time.RFC3339, gr.GeneratedAt); perr == nil {
45+
snap, loaded := e.loadedGraph()
46+
47+
// Age comes from the LOADED snapshot's own generated_at. That field is
48+
// authoritative: it is stamped on every generate and restored verbatim from
49+
// snapshot.meta.json on restart, so it always describes the graph being served.
50+
if snap != nil && snap.Meta.GeneratedAt != "" {
51+
if t, perr := time.Parse(time.RFC3339, snap.Meta.GeneratedAt); perr == nil {
4252
st.GeneratedAt = t
4353
}
4454
}
45-
if st.GeneratedAt.IsZero() {
46-
// No (or unparseable) global receipt: use the loaded snapshot's own time.
47-
if snap := e.Snapshot(); snap != nil && snap.Meta.GeneratedAt != "" {
48-
if t, perr := time.Parse(time.RFC3339, snap.Meta.GeneratedAt); perr == nil {
49-
st.GeneratedAt = t
50-
}
55+
// The graph-wide receipt is only a fallback, and only when it actually describes
56+
// a repo in the loaded graph. ~/.enola/receipt.json is shared by every enola
57+
// process on the machine and describes whichever generated last, so preferring it
58+
// unconditionally reported a sibling terminal's timestamp as this graph's age.
59+
if st.GeneratedAt.IsZero() && err == nil && gr != nil && gr.GeneratedAt != "" && describesAny(gr, loaded) {
60+
if t, perr := time.Parse(time.RFC3339, gr.GeneratedAt); perr == nil {
61+
st.GeneratedAt = t
5162
}
5263
}
5364
if !st.GeneratedAt.IsZero() {
5465
st.Age = now.Sub(st.GeneratedAt)
5566
st.TooOld = st.Age > maxAge
5667
}
5768

58-
for _, r := range e.stalenessEntries(gr, err) {
69+
for _, r := range e.stalenessEntries(gr, err, snap, loaded) {
5970
if r.Path == "" || r.Git == nil {
6071
continue // non-git or unknown: covered by the age signal only
6172
}
@@ -73,14 +84,63 @@ func (e *Engine) Staleness(maxAge time.Duration, now time.Time) Staleness {
7384
return st
7485
}
7586

76-
// stalenessEntries returns the per-repo entries to check: the global receipt's repo
77-
// list when available, otherwise a single synthetic entry from the in-memory
78-
// snapshot meta (single-repo / no global receipt).
79-
func (e *Engine) stalenessEntries(gr *facts.GraphReceipt, grErr error) []facts.GraphRepoEntry {
80-
if grErr == nil && gr != nil && len(gr.Repos) > 0 {
81-
return gr.Repos
87+
// loadedGraph returns the published snapshot together with the canonical absolute
88+
// paths of the repos that graph actually holds. Both come from ONE bundle load, so
89+
// repoPaths and snapshot are read as a consistent pair (the same reason
90+
// ResolveFactFile loads once).
91+
//
92+
// GenerateSnapshot only builds repoPaths in append mode — a single-repo snapshot
93+
// leaves it nil — so fall back to the snapshot's own RepoPath. An empty result means
94+
// nothing is loaded, which callers must treat as "cannot verify", never as "matches".
95+
func (e *Engine) loadedGraph() (*facts.Snapshot, map[string]bool) {
96+
b := e.current.Load()
97+
paths := make(map[string]bool, len(b.repoPaths)+1)
98+
for _, p := range b.repoPaths {
99+
if p != "" {
100+
paths[canonicalRepoPath(p)] = true
101+
}
102+
}
103+
if len(paths) == 0 && b.snapshot != nil && b.snapshot.Meta.RepoPath != "" {
104+
paths[canonicalRepoPath(b.snapshot.Meta.RepoPath)] = true
105+
}
106+
return b.snapshot, paths
107+
}
108+
109+
// describesAny reports whether the receipt names at least one repo that is in the
110+
// loaded graph. Paths are compared canonically because a receipt records the path as
111+
// the writing server resolved it (macOS /var vs /private/var).
112+
func describesAny(gr *facts.GraphReceipt, loaded map[string]bool) bool {
113+
if gr == nil || len(loaded) == 0 {
114+
return false
115+
}
116+
for _, r := range gr.Repos {
117+
if r.Path != "" && loaded[canonicalRepoPath(r.Path)] {
118+
return true
119+
}
120+
}
121+
return false
122+
}
123+
124+
// stalenessEntries returns the per-repo entries to check, restricted to repos that
125+
// are actually in the loaded graph. The global receipt supplies the snapshot-time git
126+
// baseline, but it is machine-wide — every enola process on the box overwrites it with
127+
// its OWN repo list — so taking it verbatim meant judging the freshness of a repo this
128+
// server had never loaded, in both directions: a false "commit moved" for a sibling
129+
// terminal's repo, and silence about the loaded repo because it was never checked.
130+
// Filtering here is what makes the wrong verdict unrepresentable rather than merely
131+
// unlikely. Falls back to a single synthetic entry from the snapshot meta.
132+
func (e *Engine) stalenessEntries(gr *facts.GraphReceipt, grErr error, snap *facts.Snapshot, loaded map[string]bool) []facts.GraphRepoEntry {
133+
if grErr == nil && gr != nil && len(gr.Repos) > 0 && len(loaded) > 0 {
134+
kept := make([]facts.GraphRepoEntry, 0, len(gr.Repos))
135+
for _, r := range gr.Repos {
136+
if r.Path != "" && loaded[canonicalRepoPath(r.Path)] {
137+
kept = append(kept, r)
138+
}
139+
}
140+
if len(kept) > 0 {
141+
return kept
142+
}
82143
}
83-
snap := e.Snapshot()
84144
if snap == nil || snap.Meta.RepoPath == "" {
85145
return nil
86146
}

internal/engine/restore_test.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package engine_test
22

33
import (
44
"context"
5+
"encoding/json"
6+
"os"
57
"os/exec"
68
"path/filepath"
9+
"strings"
710
"testing"
811
"time"
912

@@ -157,3 +160,196 @@ func TestStaleness_CommitMoved(t *testing.T) {
157160
t.Error("commit moved should make Stale() true")
158161
}
159162
}
163+
164+
// writeForeignGlobalReceipt writes ~/.enola/receipt.json describing a repo that is
165+
// NOT in the loaded graph — exactly what a second enola server in another agent
166+
// terminal leaves behind, since WriteGlobalReceipt rebuilds `repos` from its own
167+
// graph rather than merging. generatedAt is deliberately old and the commit is
168+
// deliberately wrong, so that adopting this file produces a visibly wrong verdict.
169+
//
170+
// The three staleness tests above set HOME to an empty temp dir specifically so
171+
// LoadGlobalReceipt MISSES; this helper is what exercises the branch where it hits.
172+
func writeForeignGlobalReceipt(t *testing.T, home, foreignRepoPath, generatedAt string) {
173+
t.Helper()
174+
receipt := map[string]any{
175+
"generated_at": generatedAt,
176+
"enola_version": "dev",
177+
"snapshot_id": "sha256:sibling-terminal",
178+
"fact_count": 4,
179+
"repos": []map[string]any{{
180+
"label": "foreign-sibling",
181+
"path": foreignRepoPath,
182+
"git": map[string]any{
183+
"ref": "main",
184+
"commit": "0000000000000000000000000000000000000000",
185+
"dirty": false,
186+
},
187+
"added_at": generatedAt,
188+
"fact_count": 4,
189+
}},
190+
}
191+
data, err := json.Marshal(receipt)
192+
if err != nil {
193+
t.Fatal(err)
194+
}
195+
dir := filepath.Join(home, ".enola")
196+
if err := os.MkdirAll(dir, 0o755); err != nil {
197+
t.Fatal(err)
198+
}
199+
if err := os.WriteFile(filepath.Join(dir, "receipt.json"), data, 0o644); err != nil {
200+
t.Fatal(err)
201+
}
202+
}
203+
204+
// initGitRepo creates a git repo with one commit and returns its HEAD.
205+
func initGitRepo(t *testing.T, repo string) string {
206+
t.Helper()
207+
git := func(args ...string) {
208+
t.Helper()
209+
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
210+
if out, err := cmd.CombinedOutput(); err != nil {
211+
t.Fatalf("git %v: %v\n%s", args, err, out)
212+
}
213+
}
214+
git("init")
215+
git("config", "user.email", "t@example.com")
216+
git("config", "user.name", "t")
217+
writeFile(t, filepath.Join(repo, "f.txt"), "hello\n")
218+
git("add", ".")
219+
git("commit", "-m", "init")
220+
out, err := exec.Command("git", "-C", repo, "rev-parse", "HEAD").Output()
221+
if err != nil {
222+
t.Fatal(err)
223+
}
224+
return strings.TrimSpace(string(out))
225+
}
226+
227+
// TestStaleness_IgnoresForeignGlobalReceipt is the regression for the machine-wide
228+
// receipt cross-talk. ~/.enola/receipt.json is shared by every enola process on the
229+
// machine, so it routinely describes a DIFFERENT graph than the one this server
230+
// loaded. Staleness must judge the loaded graph, never whatever the file names.
231+
//
232+
// Observed before the fix: a graph snapshotted 13 seconds earlier over a clean tree
233+
// reported "graph is 5d old; <foreign> (commit moved)".
234+
func TestStaleness_IgnoresForeignGlobalReceipt(t *testing.T) {
235+
if _, err := exec.LookPath("git"); err != nil {
236+
t.Skip("git not available")
237+
}
238+
home := t.TempDir()
239+
t.Setenv("HOME", home)
240+
241+
loaded := t.TempDir()
242+
head := initGitRepo(t, loaded)
243+
foreign := t.TempDir()
244+
initGitRepo(t, foreign)
245+
246+
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
247+
// The foreign receipt is 5 days old and names a commit that matches nothing.
248+
writeForeignGlobalReceipt(t, home, foreign, now.Add(-5*24*time.Hour).Format(time.RFC3339))
249+
250+
cfg := config.Default()
251+
eng, err := engine.New(cfg)
252+
if err != nil {
253+
t.Fatal(err)
254+
}
255+
// The loaded graph is fresh (1h) and its recorded commit is current HEAD.
256+
eng.SetSnapshot(&facts.Snapshot{Meta: facts.SnapshotMeta{
257+
RepoPath: loaded,
258+
GeneratedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
259+
Git: &facts.GitInfo{Ref: "main", Commit: head},
260+
}})
261+
262+
st := eng.Staleness(24*time.Hour, now)
263+
if st.TooOld {
264+
t.Errorf("age was taken from the foreign receipt: TooOld=true for a 1h-old loaded snapshot (Age=%s)", st.Age)
265+
}
266+
for _, c := range st.Changed {
267+
if c.Label == "foreign-sibling" {
268+
t.Errorf("reported a repo that is not in the loaded graph: %+v", c)
269+
}
270+
}
271+
if st.Stale() {
272+
t.Errorf("fresh, unchanged loaded graph reported stale: TooOld=%v Changed=%+v", st.TooOld, st.Changed)
273+
}
274+
}
275+
276+
// TestStaleness_ReportsLoadedRepoDespiteForeignReceipt is the other direction: the
277+
// foreign receipt must not MASK real staleness in the loaded graph. Before the fix
278+
// stalenessEntries returned the receipt's repo list verbatim, so the loaded repo was
279+
// never git-checked at all and a moved HEAD went unreported.
280+
func TestStaleness_ReportsLoadedRepoDespiteForeignReceipt(t *testing.T) {
281+
if _, err := exec.LookPath("git"); err != nil {
282+
t.Skip("git not available")
283+
}
284+
home := t.TempDir()
285+
t.Setenv("HOME", home)
286+
287+
loaded := t.TempDir()
288+
initGitRepo(t, loaded)
289+
foreign := t.TempDir()
290+
initGitRepo(t, foreign)
291+
292+
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
293+
writeForeignGlobalReceipt(t, home, foreign, now.Add(-5*24*time.Hour).Format(time.RFC3339))
294+
295+
cfg := config.Default()
296+
eng, err := engine.New(cfg)
297+
if err != nil {
298+
t.Fatal(err)
299+
}
300+
// Fresh by age, but the recorded commit does not match the loaded repo's HEAD.
301+
eng.SetSnapshot(&facts.Snapshot{Meta: facts.SnapshotMeta{
302+
RepoPath: loaded,
303+
GeneratedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
304+
Git: &facts.GitInfo{Ref: "main", Commit: "0000000000000000000000000000000000000000"},
305+
}})
306+
307+
st := eng.Staleness(24*time.Hour, now)
308+
if len(st.Changed) != 1 {
309+
t.Fatalf("want exactly one Changed entry for the loaded repo, got %+v", st.Changed)
310+
}
311+
if got := st.Changed[0].Label; got != filepath.Base(loaded) {
312+
t.Errorf("Changed names %q, want the loaded repo %q", got, filepath.Base(loaded))
313+
}
314+
if st.Changed[0].Reason != "commit moved" {
315+
t.Errorf("got Reason=%q, want \"commit moved\"", st.Changed[0].Reason)
316+
}
317+
}
318+
319+
// TestStaleness_AgeFromLoadedSnapshotNotForeignReceipt isolates the age signal from
320+
// the git signal: same commit on both sides, so only the timestamp can differ. The
321+
// loaded graph is 1h old and the foreign receipt is 5 days old.
322+
func TestStaleness_AgeFromLoadedSnapshotNotForeignReceipt(t *testing.T) {
323+
if _, err := exec.LookPath("git"); err != nil {
324+
t.Skip("git not available")
325+
}
326+
home := t.TempDir()
327+
t.Setenv("HOME", home)
328+
329+
loaded := t.TempDir()
330+
head := initGitRepo(t, loaded)
331+
332+
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
333+
// Foreign receipt points at the LOADED repo path but carries a 5-day-old
334+
// timestamp, so only the age source distinguishes pass from fail.
335+
writeForeignGlobalReceipt(t, home, loaded, now.Add(-5*24*time.Hour).Format(time.RFC3339))
336+
337+
cfg := config.Default()
338+
eng, err := engine.New(cfg)
339+
if err != nil {
340+
t.Fatal(err)
341+
}
342+
eng.SetSnapshot(&facts.Snapshot{Meta: facts.SnapshotMeta{
343+
RepoPath: loaded,
344+
GeneratedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
345+
Git: &facts.GitInfo{Ref: "main", Commit: head},
346+
}})
347+
348+
st := eng.Staleness(24*time.Hour, now)
349+
if st.TooOld {
350+
t.Errorf("age came from the receipt (5d), not the loaded snapshot (1h): Age=%s", st.Age)
351+
}
352+
if want := 1 * time.Hour; st.Age != want {
353+
t.Errorf("Age=%s, want %s (the loaded snapshot's own generated_at)", st.Age, want)
354+
}
355+
}

0 commit comments

Comments
 (0)