carlos / platform Public

settings-4 task 1: fix round 1: NextBuildState keeps the newest resolution; guard tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev fb1dc99dc58015548a1e4fcfadff7997ced09d34 parent 1fa26f3
2 files changed, +24 −1
  • internal/release/buildstate.go +12 −1
  • internal/release/buildstate_test.go +12 −0
diff --git a/internal/release/buildstate.go b/internal/release/buildstate.go
index 6a1fb7a7c..a8370147f 100644
--- a/internal/release/buildstate.go
+++ b/internal/release/buildstate.go
@@ -51,6 +51,8 @@ type BuildStateCopy struct {
ReadErr string `json:"read_err,omitempty"`
}
+// ParseBuildStateCopy reads the registry column. An empty column is the zero
+// copy: Known false, nothing read yet.
func ParseBuildStateCopy(col string) (BuildStateCopy, error) {
if col == "" {
return BuildStateCopy{}, nil
@@ -62,6 +64,7 @@ func ParseBuildStateCopy(col string) (BuildStateCopy, error) {
return c, nil
}
+// Column is the registry column text for c; ParseBuildStateCopy reverses it.
func (c BuildStateCopy) Column() string {
b, _ := json.Marshal(c)
return string(b)
@@ -87,6 +90,9 @@ type Consumed struct {
At time.Time `json:"at"`
}
+// ConsumedPath is the path for host's consumed file. The caller must pass a
+// host already validated with controlname.ValidHostShape (ReadConsumed and
+// WriteConsumed do); an unvalidated host could escape consumed/.
func ConsumedPath(optDir, host string) string { return filepath.Join(optDir, "consumed", host) }
// ReadConsumed is (nil, nil) when root has never written one for host.
@@ -156,7 +162,12 @@ func NextBuildState(cur *BuildState, c *Consumed, build string) BuildState {
next.Resolved = copyResolved(cur.Resolved)
}
if c != nil {
- if c.Resolved != nil && c.Resolved.ID > Watermark(nil, &next) {
+ // Carry root's resolution only when it is newer than everything the
+ // state already knows: above the watermark of both sources and above
+ // the state's own resolution. An instance that moved A to B and back
+ // meets A's old consumed file, which must not overwrite B's newer R.
+ if c.Resolved != nil && c.Resolved.ID > Watermark(c, &next) &&
+ (next.Resolved == nil || c.Resolved.ID > next.Resolved.ID) {
next.Resolved = copyResolved(c.Resolved)
}
switch {
diff --git a/internal/release/buildstate_test.go b/internal/release/buildstate_test.go
index 611c35f8b..06acc9315 100644
--- a/internal/release/buildstate_test.go
+++ b/internal/release/buildstate_test.go
@@ -115,6 +115,18 @@ func TestNextBuildState(t *testing.T) {
&BuildState{Schema: 1, Host: "h", Completed: 6},
&Consumed{Completed: 4}, "v1",
BuildState{Schema: 1, Host: "h", LastExecuted: "v1", Completed: 6}},
+ {"i: a resolution below the consumed Completed never lowers the watermark",
+ &BuildState{Schema: 1, Host: "h", Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 2},
+ &Consumed{Completed: 5, CompletedFailed: true}, "v3",
+ BuildState{Schema: 1, Host: "h", LastExecuted: "v3", Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 5, CompletedFailed: true}},
+ {"ii: a closed request whose target runs again stays closed and failed",
+ &BuildState{Schema: 1, Host: "h", Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 3, CompletedFailed: true},
+ nil, "v3",
+ BuildState{Schema: 1, Host: "h", LastExecuted: "v3", Resolved: &ResolvedRequest{ID: 3, Target: "v3"}, Completed: 3, CompletedFailed: true}},
+ {"j: an older resolution in this box's consumed file does not replace the state's newer one",
+ &BuildState{Schema: 1, Host: "h", Resolved: &ResolvedRequest{ID: 5, Target: "v5"}, Completed: 3},
+ &Consumed{Resolved: &ResolvedRequest{ID: 4, Target: "v4"}, Completed: 3}, "v5",
+ BuildState{Schema: 1, Host: "h", LastExecuted: "v5", Resolved: &ResolvedRequest{ID: 5, Target: "v5"}, Completed: 5}},
} {
t.Run(tc.name, func(t *testing.T) {
var curBefore BuildState