carlos / platform Public

settings-4 task 11a: a request's resolution in the build state outranks a box's own file

Ruling R-RESOLUTION-STATE-WINS. The Task 11 simulation (3000-seed sweep,
seed 531) found a request with two resolutions. Box A resolved R under a
pin and the instance moved before any wake, so only A's consumed file held
it. Box B resolved R again, and a wake there carried {R, T2} into the build
state. Back on A, resolvedFor followed A's file (T1), while NextBuildState
completes R only when the build state's T2 runs. R stayed pending for
ever, and rule 2 held a manual instance on T1.

- resolvedFor prefers the build state's resolution for the same id.
- NextConsumed rewrites a file that resolves the same request as a read
  build state, to another build, to the build state's T.
- heldTarget follows the same order, so the wake and root agree.

Tests: a Target row for the seed-531 shape through the wake that completes
R; three NextConsumed cases (rewrite, unread state, another id); and a
heldTarget test. No existing test pinned "the file wins" for the same id,
so none flipped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5b8abb22d8412a8a5b9a8bd182d1aa61cc50052a parent d1bd8d0
5 files changed, +87 −8
  • internal/host/buildstate.go +6 −3
  • internal/host/buildstate_test.go +15 −0
  • internal/release/adoptsettings_test.go +21 −0
  • internal/release/updatetarget.go +21 −5
  • internal/release/updatetarget_test.go +24 −0
diff --git a/internal/host/buildstate.go b/internal/host/buildstate.go
index 58f1e4ef8..f5de81452 100644
--- a/internal/host/buildstate.go
+++ b/internal/host/buildstate.go
@@ -561,13 +561,16 @@ func (b *buildStates) heldTarget(host string) activator.HeldWake {
if u.Pin != "" {
return held(u.Pin)
}
+ // The build state's resolution before the consumed file's, as root's
+ // resolvedFor (ruling R-RESOLUTION-STATE-WINS): when both name the same
+ // request, root installs the build state's T, so the wake targets it.
if bs.Known && u.InstallRequest > release.Watermark(c, bs.State) {
- if c != nil && c.Resolved != nil && c.Resolved.ID == u.InstallRequest {
- return held(c.Resolved.Target)
- }
if bs.State != nil && bs.State.Resolved != nil && bs.State.Resolved.ID == u.InstallRequest {
return held(bs.State.Resolved.Target)
}
+ if c != nil && c.Resolved != nil && c.Resolved.ID == u.InstallRequest {
+ return held(c.Resolved.Target)
+ }
}
le := ""
if bs.State != nil {
diff --git a/internal/host/buildstate_test.go b/internal/host/buildstate_test.go
index 346d3809b..98a799aa3 100644
--- a/internal/host/buildstate_test.go
+++ b/internal/host/buildstate_test.go
@@ -853,6 +853,21 @@ func TestAPinOutranksAPendingRequestAtTheWake(t *testing.T) {
}
}
+// Ruling R-RESOLUTION-STATE-WINS: when root's file and the build state
+// resolve the same request to different builds (a box that resolved R, lost
+// the instance to a move before any wake, and got it back after another box
+// resolved R again), the wake targets the build state's T, as root does.
+func TestAHeldWakeFollowsTheBuildStatesResolutionOverTheFile(t *testing.T) {
+ b, _, reg := testBuildStates(t)
+ setSettings(t, reg, release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable", InstallRequest: 1})
+ setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true,
+ State: &release.BuildState{LastExecuted: "v1", Resolved: &release.ResolvedRequest{ID: 1, Target: "v4"}}})
+ writeConsumed(t, b, release.Consumed{Reason: string(release.ReasonRequest), Desired: "v1", Resolved: &release.ResolvedRequest{ID: 1, Target: "v1"}})
+ if h := b.heldTarget("a.example.com"); !h.Held || h.Target != "v4" {
+ t.Fatalf("heldTarget = %+v; want the build state's v4, held", h)
+ }
+}
+
// Task 2 review: with the build state unread (Known false) root does no
// request bookkeeping and will not install T, so neither does the wake
// follow it. Held settings then read root's unknown; auto ones are not held.
diff --git a/internal/release/adoptsettings_test.go b/internal/release/adoptsettings_test.go
index ff7892a40..aad34b03d 100644
--- a/internal/release/adoptsettings_test.go
+++ b/internal/release/adoptsettings_test.go
@@ -620,6 +620,27 @@ func TestNextConsumed(t *testing.T) {
dec: TargetDecision{Version: "v9", Reason: ReasonAdopt, Bake: true, Detail: "v9"},
want: Consumed{Host: "a.example.com", Desired: "v9", Reason: "adopt", Detail: "v9"},
},
+ {
+ name: "R-RESOLUTION-STATE-WINS: a file disagreeing with a read build state takes its T",
+ in: TargetInput{Pointer: head, Settings: req(3), Build: known(&BuildState{LastExecuted: "v1", Resolved: &ResolvedRequest{ID: 3, Target: "v4"}}),
+ Consumed: &Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v1"}}},
+ dec: TargetDecision{Version: "v4", Reason: ReasonRequest, Evidence: EvidenceRecord, Detail: "v4"},
+ want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v4"}, Desired: "v4", Reason: "request", Detail: "v4"},
+ },
+ {
+ name: "R-RESOLUTION-STATE-WINS: an unread build state leaves the file alone",
+ in: TargetInput{Pointer: head, Settings: req(3), Build: BuildStateCopy{},
+ Consumed: &Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v1"}}},
+ dec: TargetDecision{Reason: ReasonUnknown},
+ want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v1"}, Reason: "unknown"},
+ },
+ {
+ name: "R-RESOLUTION-STATE-WINS: another request's resolution in the build state changes nothing",
+ in: TargetInput{Pointer: head, Settings: req(3), Build: known(&BuildState{LastExecuted: "v1", Resolved: &ResolvedRequest{ID: 2, Target: "v4"}, Completed: 2}),
+ Consumed: &Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v1"}}},
+ dec: TargetDecision{Version: "v1", Reason: ReasonRequest, Evidence: EvidenceRecord, Detail: "v1"},
+ want: Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 3, Target: "v1"}, Desired: "v1", Reason: "request", Detail: "v1"},
+ },
{
name: "no baking with no readable head",
in: TargetInput{HeadHeld: "", Settings: req(2), Build: known(&BuildState{LastExecuted: "v1"})},
diff --git a/internal/release/updatetarget.go b/internal/release/updatetarget.go
index e02d33a24..0a14836b5 100644
--- a/internal/release/updatetarget.go
+++ b/internal/release/updatetarget.go
@@ -187,14 +187,20 @@ func completedFailed(c *Consumed, b *BuildState, id uint64) bool {
}
// resolvedFor is R's T from either source (§4.3 rule 2: "locally or in the
-// build state"), or nil.
+// build state"), or nil. When both resolve the same R to different builds
+// the build state's wins (ruling R-RESOLUTION-STATE-WINS): it travels with
+// the data, and the pre-exec write completes R only when its T runs. A box
+// that resolved R and lost the instance to a move before any wake keeps an
+// older resolution in its own file; the destination resolved R again into
+// the build state. Following the file there would run a T that never
+// completes R.
func resolvedFor(c *Consumed, b *BuildState, id uint64) *ResolvedRequest {
- if c != nil && c.Resolved != nil && c.Resolved.ID == id {
- return c.Resolved
- }
if b != nil && b.Resolved != nil && b.Resolved.ID == id {
return b.Resolved
}
+ if c != nil && c.Resolved != nil && c.Resolved.ID == id {
+ return c.Resolved
+ }
return nil
}
@@ -215,7 +221,9 @@ func tagInMajor(tag string, major int) bool {
// ran, Astra I1 #10) the outcome changes and the request stays closed;
// below it, nothing.
// - dec.Resolve records {R, T}. With R-TARGET-FAILEDHEAD a decision can
-// resolve R and complete it failed in the same write.
+// resolve R and complete it failed in the same write. A file resolving
+// the same R as a read build state, to another T, is rewritten to the
+// build state's T (R-RESOLUTION-STATE-WINS).
// - Baking names the head a pending, unresolved request waits on, beside
// the decision's reason, never in its place (Astra I1 #8): Reason is what
// the held wake check reads. Only with the build state read
@@ -238,6 +246,14 @@ func NextConsumed(in TargetInput, dec TargetDecision) Consumed {
next.Resolved = copyResolved(dec.Resolve)
}
u, state := in.Settings, in.Build.State
+ // A file that resolves the same request as the build state, to another
+ // build, takes the build state's T (ruling R-RESOLUTION-STATE-WINS), so
+ // the box's own record stops disagreeing with what Target followed. Only
+ // with the build state read: an unread copy says nothing.
+ if in.Build.Known && state != nil && state.Resolved != nil && next.Resolved != nil &&
+ next.Resolved.ID == state.Resolved.ID && next.Resolved.Target != state.Resolved.Target {
+ next.Resolved = copyResolved(state.Resolved)
+ }
next.Baking = ""
if in.Build.Known && in.Pointer != nil && in.HeadHeld != "" && !u.InstallFastTrack && dec.Resolve == nil &&
u.InstallRequest > Watermark(&next, state) && resolvedFor(&next, state, u.InstallRequest) == nil {
diff --git a/internal/release/updatetarget_test.go b/internal/release/updatetarget_test.go
index 6c963ee1e..15e6dc7d6 100644
--- a/internal/release/updatetarget_test.go
+++ b/internal/release/updatetarget_test.go
@@ -209,3 +209,27 @@ func TestAPendingRequestOnAFailedHeadCompletesFailed(t *testing.T) {
TargetDecision{Version: "v1", Reason: ReasonManual, Evidence: EvidenceRecord}},
})
}
+
+// Ruling R-RESOLUTION-STATE-WINS (Task 11 sweep, seed 531): box A resolved R
+// to v1 and lost the instance to a move before any wake; box B resolved R
+// again, to v4, and a wake carried that into the build state; back on A, the
+// file and the build state name different builds for the same R. The build
+// state's T is followed, root's file is rewritten to it, and the wake that
+// runs it completes R. Following the file ran v1 forever with R pending.
+func TestTheBuildStatesResolutionOutranksTheBoxsFile(t *testing.T) {
+ in := TargetInput{Pointer: head("v5"), HeadTag: "1.5.0",
+ Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 1, InstallFastTrack: true},
+ Consumed: &Consumed{Host: "a.example.com", Resolved: &ResolvedRequest{ID: 1, Target: "v1"}},
+ Build: known(true, &BuildState{LastExecuted: "v1", Resolved: &ResolvedRequest{ID: 1, Target: "v4"}}), Installed: "v1"}
+ runTargetCases(t, []tcase{{"same request, two builds: the build state's T", in,
+ TargetDecision{Version: "v4", Reason: ReasonRequest, Evidence: EvidenceRecord}}})
+ dec := Target(in)
+ c := NextConsumed(in, dec)
+ if c.Resolved == nil || c.Resolved.ID != 1 || c.Resolved.Target != "v4" {
+ t.Fatalf("consumed after the pass = %+v; want the build state's R1->v4", c.Resolved)
+ }
+ st := NextBuildState(in.Build.State, &c, dec.Version)
+ if st.LastExecuted != "v4" || st.Completed != 1 || st.CompletedFailed {
+ t.Fatalf("build state after the wake = %+v; want R1 completed by v4", st)
+ }
+}