carlos / platform Public

settings-4 task 8: moves carry the build state; a landed move's staging is read as the instance's state

Settings spec rev 11 §4.3a: a move copies build-state.json with the
replica, so the destination holds the same last_executed and request
state, and a held instance never reads as new before its first wake.

- moves.BuildStateKey(host, op): <lane>/state/build-state.json.
- Phase 1 lane (sourceRound) mirrors exactly build-state.json from the
  claim holder's location in every round (mirrorBuildState, own receipts
  key <op>-state); the cut's byte-final round mirrors it too, after the
  final checkpoint, and retries if it changed under the copy.
- The installer's building step (landing and dirty return) copies the
  lane's state to holder.Location/build-state.json under the location
  lease, before the probe and first wake (ruling R-LANDING). No staged
  state places nothing. The state is not in the seal; it is copied by its
  current version.
- The rollback's return lane carries the target's state back the same way.
- buildStates.read reads a legacy move's staging (moves/<host>/, op lanes
  excluded) only with host claims and Phase 1 off (R-STAGEDREAD). It reads
  the staging whenever it holds a legacy key, as promoteStagedReplica
  promotes it (staging wins), rather than only when the own prefix is
  empty: a returning host's stale own state would otherwise be read.
  update still writes only the own prefix.
- TestALandingMovePromotesTheBuildState pins today's legacy behaviour:
  promoteStagedReplica already carries build-state.json (passes as is).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a1bb791e39cb53c84bbabf8e399c36c1acd2f993 parent 9293c11
8 files changed, +498 −3
  • internal/activator/promote_buildstate_test.go +30 −0
  • internal/host/buildstate.go +73 −3
  • internal/host/buildstate_moves_test.go +310 −0
  • internal/host/movescut.go +8 −0
  • internal/host/movesinstall.go +36 −0
  • internal/host/movesphase1.go +25 −0
  • internal/host/movesrollback.go +7 −0
  • internal/moves/staging.go +9 −0
diff --git a/internal/activator/promote_buildstate_test.go b/internal/activator/promote_buildstate_test.go
new file mode 100644
index 000000000..38fb17df6
--- /dev/null
+++ b/internal/activator/promote_buildstate_test.go
@@ -0,0 +1,30 @@
+package activator
+
+import (
+ "context"
+ "testing"
+)
+
+// Settings spec rev 11 §4.3a: a landing move carries the build state with
+// the replica. The legacy round mirrors the whole hibernate prefix to
+// moves/<host>/, and promoteStagedReplica copies every staged object over
+// the own prefix, build-state.json included. This pins today's behaviour:
+// a keep filter added to the promotion later must not drop it.
+func TestALandingMovePromotesTheBuildState(t *testing.T) {
+ b := testExecBackend(t)
+ m := newFakeMirrorStore()
+ b.Mirror = m
+ host := "moved.example.com"
+ m.put(b.Cfg.Bucket, MovesPrefix(host)+"generations/0000/a.ltx", "e1")
+ m.put(b.Cfg.Bucket, MovesPrefix(host)+"build-state.json", "e2")
+ if _, err := b.promoteStagedReplica(context.Background(), host); err != nil {
+ t.Fatal(err)
+ }
+ got, ok := m.buckets[b.Cfg.Bucket][b.Cfg.hibernatePrefix(host)+"/build-state.json"]
+ if !ok || got.ETag != "e2" {
+ t.Fatalf("the landing did not carry build-state.json beside the replica: %+v %v", got, ok)
+ }
+ if _, ok := m.buckets[b.Cfg.Bucket][b.Cfg.hibernatePrefix(host)+"/generations/0000/a.ltx"]; !ok {
+ t.Fatal("the landing did not carry the replica")
+ }
+}
diff --git a/internal/host/buildstate.go b/internal/host/buildstate.go
index a7d3565f0..fec24a364 100644
--- a/internal/host/buildstate.go
+++ b/internal/host/buildstate.go
@@ -14,6 +14,7 @@ import (
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -35,9 +36,13 @@ type buildStates struct {
// doClaimWake), so every host is located through it.
claims func(context.Context, string) (hostclaim.Observed, error)
claimBucket string // the claim actor's bucket, where claimed wakes lease
- storeFor func(ctx context.Context, bucket string) (release.Store, error)
- reg *registry.Registry
- optDir string
+ // staged is the legacy move round's staging prefix for host in the
+ // hibernate bucket (activator.MovesPrefix). nil unless host claims and
+ // Phase 1 are both off (ruling R-STAGEDREAD).
+ staged func(host string) string
+ storeFor func(ctx context.Context, bucket string) (release.Store, error)
+ reg *registry.Registry
+ optDir string
// bakeDir is the data dir every agent and root pass hands release.BakeDir:
// root's refusal records (release.ReadRefusal) live under it.
bakeDir string
@@ -68,6 +73,12 @@ func newBuildStates(cfg *activator.Config, reg *registry.Registry, optDir string
reg: reg, optDir: optDir, bakeDir: dataDir(), now: time.Now}
if cfg.HostWrites.Enabled {
b.claims, b.claimBucket = cfg.Claims.Read, cfg.Claims.Bucket
+ } else if !moves.Enabled(os.Getenv) {
+ // The legacy move round's staging is promoted at the first wake's
+ // restore only with Phase 1 off (ExecBackend.Phase1, set from the
+ // same switch); with host claims on the state is at the claim
+ // holder's location, which a Phase 1 landing fills.
+ b.staged = activator.MovesPrefix
}
b.storeFor = cachedStores(regions, lease.ExecutionStore)
return b, nil
@@ -200,7 +211,19 @@ func hasReplica(ctx context.Context, store release.Store, prefix string) (bool,
// read returns host's build state (nil when there is none) and whether its
// replica has any object besides the lease's lock.json and build-state.json.
+//
+// A legacy move's staging, when there is one, is read in place of the own
+// prefix (settings spec rev 11 §4.3a): between the cut and the first wake
+// the replica and the build state are only there, and the wake's restore
+// promotes the staging over the own prefix whenever it holds anything
+// (promoteStagedReplica: staging wins, and its mirror deletes what the
+// staging lacks). So the staging is what the instance's state will be. A
+// held instance must not read as new in that window, or root installs the
+// head. update still writes only the own prefix: the wake promotes first.
func (b *buildStates) read(ctx context.Context, host string) (*release.BuildState, bool, error) {
+ if st, replica, ok, err := b.readStaged(ctx, host); err != nil || ok {
+ return st, replica, err
+ }
store, prefix, err := b.locate(ctx, host)
if err != nil {
return nil, false, err
@@ -216,6 +239,53 @@ func (b *buildStates) read(ctx context.Context, host string) (*release.BuildStat
return st, replica, nil
}
+// readStaged reads a legacy move's staging for host (ruling R-STAGEDREAD):
+// ok is true when moves/<host>/ in the hibernate bucket holds any key of
+// the legacy round's, the same "anything staged" promoteStagedReplica
+// promotes on. Phase 1 op lanes (moves/<host>/<mv_ or rb_ id>/...) are not
+// the legacy round's and are skipped. The staged build-state.json is the
+// state, and any other staged object but lock.json is a replica. Off (ok
+// false) unless staged is set: host claims and Phase 1 both off.
+func (b *buildStates) readStaged(ctx context.Context, host string) (*release.BuildState, bool, bool, error) {
+ if b.staged == nil {
+ return nil, false, false, nil
+ }
+ prefix := strings.TrimSuffix(b.staged(host), "/")
+ store, err := b.storeFor(ctx, b.hibBucket)
+ if err != nil {
+ return nil, false, false, err
+ }
+ keys, err := store.List(ctx, prefix+"/")
+ if err != nil {
+ return nil, false, false, fmt.Errorf("list the staged move %s: %w", prefix, err)
+ }
+ var staged, state, replica bool
+ for _, k := range keys {
+ rel := strings.TrimPrefix(k, prefix+"/")
+ if first, _, ok := strings.Cut(rel, "/"); ok && moves.ValidOpID(first) {
+ continue
+ }
+ staged = true
+ switch rel {
+ case "build-state.json":
+ state = true
+ case "lock.json":
+ default:
+ replica = true
+ }
+ }
+ if !staged {
+ return nil, false, false, nil
+ }
+ var st *release.BuildState
+ if state {
+ if st, _, err = getState(ctx, store, release.BuildStateKey(prefix), host); err != nil {
+ return nil, false, false, err
+ }
+ }
+ return st, replica, true, nil
+}
+
// refresh stores what read found. A failed read keeps the last known state
// for root (a known copy is not wiped by a blip; an unread one stays
// unknown) and records the failure in ReadErr, so the box's report says this
diff --git a/internal/host/buildstate_moves_test.go b/internal/host/buildstate_moves_test.go
new file mode 100644
index 000000000..a0f343c24
--- /dev/null
+++ b/internal/host/buildstate_moves_test.go
@@ -0,0 +1,310 @@
+package host
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Settings spec rev 11 §4.3a: a move carries the build state with the
+// replica, and a held instance never reads as new before its first wake on
+// the destination.
+
+const stagedStateHost = "a.example.com"
+
+func stagedStateBody(host, le string) string {
+ return `{"schema":1,"host":"` + host + `","last_executed":"` + le + `","at":"2026-10-07T12:00:00Z"}`
+}
+
+func putObject(t *testing.T, s release.Store, key, body string) {
+ t.Helper()
+ if _, err := s.Put(context.Background(), key, []byte(body), ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// legacyStaged is newBuildStates' staged prefix: the legacy round's
+// moves/<host>/, read only with host claims and Phase 1 off.
+func legacyStaged(h string) string { return activator.MovesPrefix(h) }
+
+// Before the first wake promotes a legacy landing's staging, the own prefix
+// is empty. The staged build-state.json is the state and a staged object is
+// a replica, so a held instance is held on its last_executed, not given the
+// head as a new instance.
+func TestAStagedMoveIsReadAsTheInstancesState(t *testing.T) {
+ b, s, _ := testBuildStates(t)
+ b.staged = legacyStaged
+ ctx := context.Background()
+ putObject(t, s, "moves/"+stagedStateHost+"/generations/0000/x.ltx", "x")
+ putObject(t, s, "moves/"+stagedStateHost+"/build-state.json", stagedStateBody(stagedStateHost, "v1"))
+ st, replica, err := b.read(ctx, stagedStateHost)
+ if err != nil || !replica || st == nil || st.LastExecuted != "v1" {
+ t.Fatalf("read = %+v, %v, %v; want the staged state and a replica", st, replica, err)
+ }
+ // And Target therefore holds a manual instance on v1, not the head.
+ d := release.Target(release.TargetInput{Pointer: &release.ChannelPointer{Version: "v3"},
+ Settings: release.UpdateSettings{Updates: release.UpdatesManual},
+ Build: release.BuildStateCopy{Known: true, Replica: replica, State: st}})
+ if d.Version != "v1" {
+ t.Fatalf("Target = %+v", d)
+ }
+ // refresh records the same in the registry copy root reads.
+ if err := b.refresh(ctx, stagedStateHost); err != nil {
+ t.Fatal(err)
+ }
+ if c := copyOf(t, b.reg); !c.Known || !c.Replica || c.State == nil || c.State.LastExecuted != "v1" {
+ t.Fatalf("copy = %+v", c)
+ }
+ // A staged replica with no build state is still a replica: never new.
+ b2, s2, _ := testBuildStates(t)
+ b2.staged = legacyStaged
+ putObject(t, s2, "moves/"+stagedStateHost+"/generations/0000/x.ltx", "x")
+ if st, replica, err := b2.read(ctx, stagedStateHost); err != nil || !replica || st != nil {
+ t.Fatalf("staged replica only: read = %+v, %v, %v", st, replica, err)
+ }
+}
+
+// The staged copy is read the way the wake's restore will use it
+// (promoteStagedReplica: staging wins when present, and its mirror deletes
+// what staging lacks). A host that comes back to a box it left keeps its
+// old lineage and build state at the own prefix; the restore replaces them
+// with the staging, so the staged build state is the one that counts.
+// With nothing staged the own prefix is read as before.
+func TestAStagedMoveOutranksAnOlderOwnStateAsTheRestoreDoes(t *testing.T) {
+ b, s, _ := testBuildStates(t)
+ b.staged = legacyStaged
+ ctx := context.Background()
+ own := "inst/" + stagedStateHost
+ putObject(t, s, own+"/generations/0000/old.ltx", "old")
+ putObject(t, s, own+"/build-state.json", stagedStateBody(stagedStateHost, "v1"))
+ if st, replica, err := b.read(ctx, stagedStateHost); err != nil || !replica || st == nil || st.LastExecuted != "v1" {
+ t.Fatalf("own prefix alone: read = %+v, %v, %v", st, replica, err)
+ }
+ putObject(t, s, "moves/"+stagedStateHost+"/generations/0000/new.ltx", "new")
+ putObject(t, s, "moves/"+stagedStateHost+"/build-state.json", stagedStateBody(stagedStateHost, "v2"))
+ if st, replica, err := b.read(ctx, stagedStateHost); err != nil || !replica || st == nil || st.LastExecuted != "v2" {
+ t.Fatalf("staged over own: read = %+v, %v, %v; want the staged v2", st, replica, err)
+ }
+ // update still writes only the own prefix: the wake promotes first.
+ if _, err := b.update(ctx, stagedStateHost, func(cur *release.BuildState) release.BuildState {
+ return release.BuildState{LastExecuted: "v2"}
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if raw, _, err := s.Get(ctx, "moves/"+stagedStateHost+"/build-state.json"); err != nil || !strings.Contains(string(raw), `"v2"`) {
+ t.Fatalf("update touched the staging: %s %v", raw, err)
+ }
+}
+
+// Only the legacy round's own keys are its staging. moves/<host>/ also
+// holds Phase 1 op lanes (moves/<host>/<mv_ or rb_ id>/...), which no
+// legacy restore promotes and which say nothing about this box's state.
+func TestTheStagedReadExcludesPhase1OpLanes(t *testing.T) {
+ b, s, _ := testBuildStates(t)
+ b.staged = legacyStaged
+ ctx := context.Background()
+ for _, id := range []string{p1ID, p1RB} {
+ putObject(t, s, moves.ReplicaPrefix(stagedStateHost, id)+"0000/x.ltx", "x")
+ putObject(t, s, moves.BuildStateKey(stagedStateHost, id), stagedStateBody(stagedStateHost, "v9"))
+ }
+ if st, replica, err := b.read(ctx, stagedStateHost); err != nil || replica || st != nil {
+ t.Fatalf("read = %+v, %v, %v; want no state and no replica (op lanes only)", st, replica, err)
+ }
+ // A legacy key beside the op lanes is staging again.
+ putObject(t, s, "moves/"+stagedStateHost+"/build-state.json", stagedStateBody(stagedStateHost, "v1"))
+ if st, _, err := b.read(ctx, stagedStateHost); err != nil || st == nil || st.LastExecuted != "v1" {
+ t.Fatalf("read = %+v, %v; want the legacy staged v1", st, err)
+ }
+}
+
+// The staged read is the legacy round's: with Phase 1 on the restore never
+// promotes moves/<host>/ (ExecBackend.Phase1), and with host claims on the
+// state is at the claim holder's location, so newBuildStates leaves it off.
+func TestTheStagedReadIsOffWithPhase1OrClaimsOn(t *testing.T) {
+ cfg := &activator.Config{Bucket: "hib", Prefix: "inst"}
+ t.Setenv(moves.SwitchEnv, "off")
+ b, err := newBuildStates(cfg, nil, "")
+ if err != nil || b == nil || b.staged == nil || b.staged(stagedStateHost) != "moves/"+stagedStateHost+"/" {
+ t.Fatalf("Phase 1 off: staged = %v (%v)", b, err)
+ }
+ t.Setenv(moves.SwitchEnv, "on")
+ if b, err := newBuildStates(cfg, nil, ""); err != nil || b == nil || b.staged != nil {
+ t.Fatalf("Phase 1 on: the staged read is armed (%v)", err)
+ }
+ t.Setenv(moves.SwitchEnv, "off")
+ read := func(context.Context, string) (hostclaim.Observed, error) { return hostclaim.Observed{}, nil }
+ claimed := &activator.Config{Bucket: "hib", Prefix: "inst", HostWrites: hostwrite.Config{Enabled: true},
+ Claims: &activator.ClaimRuntime{Bucket: "data", Read: read}}
+ if b, err := newBuildStates(claimed, nil, ""); err != nil || b == nil || b.staged != nil {
+ t.Fatalf("claims on: the staged read is armed (%v)", err)
+ }
+
+ // With it off, a staged copy is not read.
+ b2, s, _ := testBuildStates(t)
+ putObject(t, s, "moves/"+stagedStateHost+"/build-state.json", stagedStateBody(stagedStateHost, "v1"))
+ putObject(t, s, "moves/"+stagedStateHost+"/generations/0000/x.ltx", "x")
+ if st, replica, err := b2.read(context.Background(), stagedStateHost); err != nil || replica || st != nil {
+ t.Fatalf("read = %+v, %v, %v; want nothing staged read", st, replica, err)
+ }
+}
+
+// p1StateBody is a build state for the Phase 1 harness's host.
+func p1StateBody(le string) string { return stagedStateBody(p1Host, le) }
+
+// storeState puts host's build state naming le at bucket/key, its ETag
+// unique to le as a real object's is to its bytes.
+func storeState(h *phase1Harness, bucket, key, le string) {
+ body := p1StateBody(le)
+ h.f.store(bucket, key, fakeMoveObject{lease.MirrorObject{ETag: "bs-" + le, Size: int64(len(body))}, body})
+}
+
+// The Phase 1 replica lane carries the build state beside the replica in
+// every round: exactly build-state.json from the claim holder's location,
+// under the lane's state/ prefix; a later round carries a later state.
+func TestThePhase1LaneCopiesTheBuildState(t *testing.T) {
+ h := newPhase1Harness(t)
+ h.start(t, "manual")
+ loc := strings.TrimSuffix(h.claim(t).Holder.Location, "/")
+ storeState(h, "data-ie", loc+"/build-state.json", "v6")
+ h.pass(t)
+ key := moves.BuildStateKey(p1Host, p1ID)
+ if got := h.f.buckets["data-uk"][key].body; got != p1StateBody("v6") {
+ t.Fatalf("the lane staged %q at %s, want the holder's build state", got, key)
+ }
+ state := stagedKeys(h, moves.StagingPrefix(p1Host, p1ID)+"state/")
+ if strings.Join(state, ",") != "build-state.json" {
+ t.Fatalf("the state lane holds %v, want only build-state.json", state)
+ }
+ storeState(h, "data-ie", loc+"/build-state.json", "v7")
+ h.pass(t)
+ if got := h.f.buckets["data-uk"][key].body; got != p1StateBody("v7") {
+ t.Fatalf("the next round staged %q, want the later state", got)
+ }
+ // Every write of the state lane went through the guard.
+ var guarded bool
+ for _, c := range h.f.stagingCalls() {
+ if c.Key == key {
+ guarded = !c.SignAt.IsZero()
+ }
+ }
+ if !guarded {
+ t.Fatalf("the state copy did not go through the guard: %+v", h.f.stagingCalls())
+ }
+}
+
+// The cut's byte-final round carries the final build state, not the one a
+// preparing round staged before the instance last ran.
+func TestTheCutsFinalRoundCopiesTheBuildState(t *testing.T) {
+ h := newCutHarness(t)
+ storeState(h.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v6")
+ h.ready(t) // the holder is at cutRoot from here
+ if got := h.f.buckets["data-uk"][moves.BuildStateKey(p1Host, p1ID)].body; got != p1StateBody("v6") {
+ t.Fatalf("the preparing rounds staged %q", got)
+ }
+ // The instance's last run before the stop wrote a later state: only the
+ // final round, after the final checkpoint, can carry it.
+ checkpoint := h.cfg.FinalCheckpoint
+ h.cfg.FinalCheckpoint = func(ctx context.Context, host, path string, holder hostclaim.Holder, check func(context.Context) error) error {
+ storeState(h.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v7")
+ return checkpoint(ctx, host, path, holder, check)
+ }
+ h.cut(t)
+ h.pass(t)
+ if m := h.move(t); m.State != provision.MoveLanding {
+ t.Fatalf("cut did not finish: %+v", m)
+ }
+ if got := h.f.buckets["data-uk"][moves.BuildStateKey(p1Host, p1ID)].body; got != p1StateBody("v7") {
+ t.Fatalf("the cut staged %q, want the final state", got)
+ }
+}
+
+// The landing places the staged build state beside the location's lease,
+// holder.Location/build-state.json in the target's own bucket, before the
+// probe: the probe's and the first wake's record merge onto the moved
+// state, and a held instance never reads as new there.
+func TestTheLandingPlacesTheBuildStateBeforeTheProbe(t *testing.T) {
+ h := newLandingHarness(t)
+ storeState(h.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v6")
+ h.flip(t)
+ if got := h.f.buckets["data-uk"][moves.BuildStateKey(p1Host, p1ID)].body; got != p1StateBody("v6") {
+ t.Fatalf("the lane holds %q", got)
+ }
+ dst := cutRoot + "/build-state.json"
+ if _, ok := h.f.buckets["data-uk"][dst]; ok {
+ t.Fatal("the build state is at the location before the landing")
+ }
+ h.land(t)
+ h.installed(t)
+ if got := h.f.buckets["data-uk"][dst].body; got != p1StateBody("v6") {
+ t.Fatalf("after the install the location holds %q, want the moved state", got)
+ }
+ // The probe sees it.
+ if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, func(rec *provision.Record) error {
+ rec.Move.CutAttempts = 1
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ p := &probeLog{t: t, h: h}
+ var seen []string
+ p.before = func(int) { seen = append(seen, h.f.buckets["data-uk"][dst].body) }
+ h.probe, h.probeLive = p.wake, p.liveProbe
+ h.holdProbed = true
+ h.land(t)
+ if len(seen) == 0 || seen[0] != p1StateBody("v6") {
+ t.Fatalf("the probe saw %q, want the moved state", seen)
+ }
+}
+
+// A landing whose source had no build state stages none and places none.
+func TestALandingWithNoBuildStatePlacesNone(t *testing.T) {
+ h := newLandingHarness(t)
+ h.flip(t)
+ h.land(t)
+ h.installed(t)
+ if _, ok := h.f.buckets["data-uk"][cutRoot+"/build-state.json"]; ok {
+ t.Fatal("a build state appeared at the location")
+ }
+}
+
+// The rollback's return lane carries the target's build state back, under
+// the lane's state/ prefix in the source's bucket.
+func TestTheReturnLaneCarriesTheBuildState(t *testing.T) {
+ h := newRollbackHarness(t)
+ storeState(h.phase1Harness, "data-uk", cutRoot+"/build-state.json", "v8")
+ h.uncover(t)
+ h.rollBack(t)
+ h.rollbackPass(t)
+ rb := h.rb(t)
+ if rb.TargetPhase != provision.RbTargetReturned {
+ t.Fatalf("rollback %+v", rb)
+ }
+ if got := h.f.buckets["data-ie"][moves.BuildStateKey(p1Host, rb.ID)].body; got != p1StateBody("v8") {
+ t.Fatalf("the return lane holds %q, want the target's state", got)
+ }
+}
+
+// The dirty return's install places the returned build state beside the
+// source's location lease, as the landing does.
+func TestTheReturnInstallPlacesTheBuildState(t *testing.T) {
+ h := newReturnHarness(t, true)
+ if _, err := h.vs.PutIfAbsent(context.Background(), "data-ie", moves.BuildStateKey(p1Host, p1RB), []byte(p1StateBody("v8"))); err != nil {
+ t.Fatal(err)
+ }
+ pinned := h.read(t, p1Host).Move.Inputs
+ if refused, err := h.install(t); err != nil || refused != "" {
+ t.Fatalf("install: %q %v", refused, err)
+ }
+ h.installedReturn(t, pinned)
+ if got := h.f.buckets["data-ie"][cutRoot+"/build-state.json"].body; got != p1StateBody("v8") {
+ t.Fatalf("the source's location holds %q, want the returned state", got)
+ }
+}
diff --git a/internal/host/movescut.go b/internal/host/movescut.go
index 6605bb021..99dab6d3e 100644
--- a/internal/host/movescut.go
+++ b/internal/host/movescut.go
@@ -913,6 +913,14 @@ func (c *cutRun) finalRound(ctx context.Context, holder hostclaim.Holder, report
if err != nil {
return false, fmt.Errorf("final round: %w", err)
}
+ // The build state of the stopped instance, so the lane carries the
+ // final state and not a round-old one (settings spec rev 11 §4.3a). A
+ // state that changed under the copy is not final: the pass retries.
+ if st, err := mirrorBuildState(ctx, c.gm, cfg.OwnBucket, holder.Location, dst, host, id); err != nil {
+ return false, fmt.Errorf("final round: build state lane: %w", err)
+ } else if st.Deferred > 0 {
+ return false, fmt.Errorf("final round: the build state changed during the copy; retrying the cut")
+ }
if err := c.fault("after-final-round"); err != nil {
return false, err
}
diff --git a/internal/host/movesinstall.go b/internal/host/movesinstall.go
index 972297786..941a71c0e 100644
--- a/internal/host/movesinstall.go
+++ b/internal/host/movesinstall.go
@@ -577,6 +577,9 @@ func (ir *installRun) building(ctx context.Context) (bool, error) {
if err != nil {
return false, fmt.Errorf("build the generation: %w", err)
}
+ if err := ir.placeBuildState(leaseContext(ctx, held), leasedStore{VersionedStore: vs, check: held.Check}, holder); err != nil {
+ return false, err
+ }
if err := held.Check(ctx); err != nil {
return false, fmt.Errorf("the location's lease after the build: %w", err)
}
@@ -600,6 +603,39 @@ func (ir *installRun) building(ctx context.Context) (bool, error) {
return ir.advance(ctx, phase, next, installStamp{})
}
+// placeBuildState copies the lane's build state (moves.BuildStateKey, the
+// only object under the lane's state/ prefix) beside the location's lease,
+// holder.Location/build-state.json in this box's own bucket (settings spec
+// rev 11 §4.3a, ruling R-LANDING). building calls it under the location's
+// lease, so it lands before the probe and the first wake: their record
+// merges onto the moved state, and a held instance never reads as new
+// here. The object is copied by its current version, as the source's
+// rounds left it; the cut's final round wrote the last one. A lane with no
+// build state (the source had none) places nothing. A replay copies again.
+func (ir *installRun) placeBuildState(ctx context.Context, vs lease.VersionedStore, holder hostclaim.Holder) error {
+ bucket := ir.cfg().OwnBucket
+ src := ir.src.Lane + "state/" + buildStateName
+ objs, err := vs.ListVersions(ctx, bucket, src)
+ if err != nil {
+ return fmt.Errorf("list the lane's build state: %w", err)
+ }
+ var cur *lease.ObjectVersion
+ for i := range objs {
+ if o := &objs[i]; o.Key == src && o.IsLatest {
+ cur = o
+ }
+ }
+ if cur == nil || cur.DeleteMarker {
+ return nil
+ }
+ dst := release.BuildStateKey(strings.TrimSuffix(holder.Location, "/"))
+ if _, _, err := vs.CopyVersion(ctx, bucket, src, cur.VersionID, bucket, dst, false); err != nil {
+ return fmt.Errorf("place the build state at %s: %w", dst, err)
+ }
+ log.Printf("moves: %s: the %s placed the moved build state at %s", ir.rec.Host, ir.src.Name, dst)
+ return nil
+}
+
// published (§7.6): the build is complete and recorded; generation.json is
// not written here (R10-3: the probe's cold admission publishes it). The
// journal records the phase, the record advances to local and the location's
diff --git a/internal/host/movesphase1.go b/internal/host/movesphase1.go
index 0e2eb9f8e..09284c180 100644
--- a/internal/host/movesphase1.go
+++ b/internal/host/movesphase1.go
@@ -389,6 +389,10 @@ func (w *movesWorker) sourceRound(ctx context.Context, s release.Store, rec prov
if err != nil {
return nil, nil, fmt.Errorf("replica lane: %w", err)
}
+ // The build state travels with the replica, in every round.
+ if _, err := mirrorBuildState(ctx, gm, cfg.OwnBucket, obs.Claim.Holder.Location, tgt.DataBucket, rec.Host, id); err != nil {
+ return nil, nil, fmt.Errorf("build state lane: %w", err)
+ }
if round.BytesCopied >= crossMinBytes {
ledger.LastRoundBytes, ledger.LastRoundSeconds, ledger.LastRoundAt = round.BytesCopied, cfg.Now().Sub(start).Seconds(), cfg.Now().UTC()
} else if _, at := ledger.crossRate(); at.IsZero() || cfg.Now().Sub(at) > crossRefreshAfter {
@@ -427,6 +431,27 @@ func (w *movesWorker) sourceRound(ctx context.Context, s release.Store, rec prov
return report(sr)
}
+// buildStateName is the build-state object's name beside a lease
+// (release.BuildStateKey) and in a lane's state/ prefix.
+const buildStateName = "build-state.json"
+
+// mirrorBuildState is the build-state lane (settings spec rev 11 §4.3a):
+// the instance's build-state.json, beside the lease at location in
+// srcBucket, mirrored to opID's lane state/ prefix in dstBucket, and
+// nothing else. The destination must know which build last ran against
+// this data. A source with no build state leaves none in the lane (the
+// mirror deletes a stale copy). The preparing rounds, the cut's final round
+// and a rollback's return lane all call it, so the lane holds the state of
+// the round the replica is from. Receipts live in srcBucket beside the
+// replica lane's, under their own key.
+func mirrorBuildState(ctx context.Context, m lease.MirrorStore, srcBucket, location, dstBucket, host, opID string) (lease.MirrorRound, error) {
+ src := strings.TrimSuffix(location, "/") + "/"
+ dst := strings.TrimSuffix(moves.BuildStateKey(host, opID), buildStateName)
+ keep := func(rel string) bool { return rel == buildStateName }
+ return lease.MirrorPrefixKeep(ctx, m, srcBucket, src, dstBucket, dst,
+ lease.Receipts{Bucket: srcBucket, Key: moves.ReceiptsKey(host, opID+"-state")}, keep)
+}
+
// oneBoxRefusal is the one-box rule (D15), re-checked from the edges
// snapshot every round (ruling G13): either region publishing more than one
// box refuses with M-C21's sentence. It counts every box that publishes the
diff --git a/internal/host/movesrollback.go b/internal/host/movesrollback.go
index a3e2a4242..36b3e41fa 100644
--- a/internal/host/movesrollback.go
+++ b/internal/host/movesrollback.go
@@ -704,6 +704,13 @@ func (r *rollbackRun) returning(ctx context.Context) (bool, error) {
if err != nil {
return false, err
}
+ // The target's build state goes back with its data (settings spec rev 11
+ // §4.3a): the source's install places it beside its own lease.
+ if h := obs.Claim.Holder; h == nil {
+ return false, fmt.Errorf("move %s: the claim has no holder to return the build state from", id)
+ } else if _, err := mirrorBuildState(ctx, gm, cfg.OwnBucket, h.Location, gm.dst, host, r.rb); err != nil {
+ return false, fmt.Errorf("return the build state: %w", err)
+ }
// The bound is read after the last lane write: every request the guard
// let a writer sign expires before it.
bound, err := held.SafeUntil()
diff --git a/internal/moves/staging.go b/internal/moves/staging.go
index 6adca63f3..364969a4b 100644
--- a/internal/moves/staging.go
+++ b/internal/moves/staging.go
@@ -11,6 +11,15 @@ func FilesPrefix(host, opID string) string { return StagingPrefix(host, opID)
func TimersPrefix(host, opID string) string { return StagingPrefix(host, opID) + "timers/" }
func MoveJSONKey(host, opID string) string { return StagingPrefix(host, opID) + "move.json" }
+// BuildStateKey is the lane's copy of the instance's build-state.json
+// (settings spec rev 11 §4.3a): the source mirrors it from beside the
+// claim holder's lease in every round and in the cut's final round, and
+// the installer places it beside the destination's lease before the probe.
+// It is the only object under the lane's state/ prefix.
+func BuildStateKey(host, opID string) string {
+ return StagingPrefix(host, opID) + "state/build-state.json"
+}
+
// ScopeLocation is the lane as a park scope location: no trailing slash
// (claim rev 6.23 §2 grammar, INV-23).
func ScopeLocation(host, opID string) string { return "moves/" + host + "/" + opID }