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>
5 files changed,
+87
−8
internal/host/buildstate.go+6 −3internal/host/buildstate_test.go+15 −0internal/release/adoptsettings_test.go+21 −0internal/release/updatetarget.go+21 −5internal/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) |
| + } |
| +} |