stacks: R-687 — an empty leg reports steps [], and a taken files_may_change step names its whole copy
gates / gates (push) Successful in 26s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-25 13:24:19 +02:00
parent b6399fdf0b
commit 1c5dd07604
2 changed files with 26 additions and 3 deletions
+3 -1
View File
@@ -186,7 +186,7 @@ func (m *Manager) LastUpdateLegSummary() *UpdateLegSummary {
return nil
}
c := *m.leg.last
c.Steps = append([]LegStep(nil), m.leg.last.Steps...)
c.Steps = append([]LegStep{}, m.leg.last.Steps...) // R-687: `[]`, never `null`, in the hub report
return &c
}
@@ -437,6 +437,8 @@ func (m *Manager) legCandidate(ctx context.Context, name string, o *UpdateLegOpt
m.logger.Printf("[INFO] [update-leg] %s: the step may change the app's files and no fresh whole copy exists (%s)", name, why)
return e, LegSkipFilesNoCopy
}
// R-687: the TAKEN case names the copy that allowed it, as the skip names why not.
m.logger.Printf("[INFO] [update-leg] %s: the step may change the app's files — taken, a fresh whole copy exists: %s", name, why)
}
return e, ""
}
+23 -2
View File
@@ -3,6 +3,7 @@ package stacks
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"strconv"
"strings"
@@ -227,13 +228,13 @@ func TestLeg_NeedsPersonIsNeverTaken(t *testing.T) {
// TestLeg_FilesMayChangeNeedsAWholeCopy — decision 13's other mark: taken only when a fresh WHOLE copy
// exists (the backup side's truth table, asked through FreshWholeCopy).
func TestLeg_FilesMayChangeNeedsAWholeCopy(t *testing.T) {
m, dir, _, ups, _ := legManager(t)
m, dir, _, ups, logBuf := legManager(t)
writeLadder(t, m,
ladderLineMarks(ladderA, ladderB, "proven", `{"files_may_change": true, "needs_person": null, "memory_tight": false}`),
ladderLine(ladderB, ladderC))
whole := false
legOpts(m, func(o *UpdateLegOptions) {
o.FreshWholeCopy = func(context.Context, string) (bool, string) { return whole, "fake" }
o.FreshWholeCopy = func(context.Context, string) (bool, string) { return whole, "tier 2 copy from FAKE-TIME" }
})
s := mustLeg(t, m)
if len(*ups) != 0 || legStepFor(s, "nextcloud").Reason != LegSkipFilesNoCopy {
@@ -244,6 +245,10 @@ func TestLeg_FilesMayChangeNeedsAWholeCopy(t *testing.T) {
if len(*ups) != 1 || pinOf(t, dir) != ladderB || s.Done != 1 {
t.Fatalf("with a fresh whole copy the step must be taken: ups=%v pin=%s", *ups, pinOf(t, dir))
}
// R-687 (v0.273.0): the taken step names the copy that allowed it. COMPANION RED-PROOF: drop the log line.
if !strings.Contains(logBuf.String(), "taken, a fresh whole copy exists: tier 2 copy from FAKE-TIME") {
t.Fatal("a taken files_may_change step must log which whole copy allowed it")
}
// unwired check = no whole copy (fail closed)
m2, _, _, ups2, _ := legManager(t)
writeLadder(t, m2,
@@ -452,3 +457,19 @@ func TestLegDeadline(t *testing.T) {
}
}
}
// TestR687_EmptyLegReportsEmptySteps — a leg that pressed nothing reports `"steps": []` to the hub, not
// `null` (read live 2026-09-25 from demo-hp's report). COMPANION RED-PROOF: copy with append(nil, …).
func TestR687_EmptyLegReportsEmptySteps(t *testing.T) {
m, _, _, _, _ := legManager(t)
m.leg.mu.Lock()
m.leg.last = &UpdateLegSummary{Trigger: "after-offsite", Steps: []LegStep{}}
m.leg.mu.Unlock()
b, err := json.Marshal(m.LastUpdateLegSummary())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"steps":[]`) {
t.Fatalf("an empty leg must report steps as [], got %s", b)
}
}