host: local builds the prepared files manifest; a journalled skip is replayed (Task 6, C8)
The files_manifest step of local no longer records "skipped": it builds
the prepared manifest through buildPreparedManifest over the install's
own versioned store (create-only, bound to {location, g-<generation>,
op, stamp}, the generation being the move id or a dirty return's
rollback id, never holder.generation), with the placed files, <host>.env
and the wake state (hostfiles.WakeStateOf over the sealed timers, the
next wake and the replication class), and journals the binding with the
manifest's digest. A journal that holds the old "skipped" value counts
as not done, so a replay builds it (ruling Q3 item 2). A manifest
already at the key that is not this install's is refused: the landing
rolls back with RollbackManifestOccupied (MV-N454) and a dirty return
goes stuck with StuckReturnManifestOccupied (MV-N456); both rows pending
in the copy record.
Tests: the manifest is bound to the new generation at sequence 0 while
holder.generation stays the source's; a planted skip is built on
replay; a cross-region landing's manifest names destination-local
objects only; a foreign manifest is refused and left as it was; nothing
is written under files/ before the step; the phase-idempotence sweep and
the dirty-return install tests pass unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7 files changed,
+287
−34
approved_moves_copy_test.go+1 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+7 −0internal/host/movesinstall.go+56 −13internal/host/movesland.go+7 −3internal/host/movesland_test.go+183 −10internal/hostfiles/wake.go+11 −2internal/provision/provision.go+22 −5
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 6d98e4f3d..693d1f76a 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -86,7 +86,7 @@ var movesCopyIDs = []string{ |
| "MV-N380", "MV-N381", |
| "MV-N385", "MV-N386", "MV-N387", "MV-N389", "MV-N390", |
| "MV-N440", "MV-N441", "MV-N442", "MV-N443", |
| - "MV-N450", "MV-N451", "MV-N455", |
| + "MV-N450", "MV-N451", "MV-N454", "MV-N455", "MV-N456", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index f11cac8f2..eb813bbce 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -431,3 +431,10 @@ Task 7's row is T7's reason under the claim (Paul's T7 ruling of 2026-10-12). Wi |
| | id | file | text | status | |
| |---|---|---|---| |
| | MV-N455 | internal/host/movesreport.go | the release is not staged on this box yet | pending | |
| + |
| +Task 6's two rows are the prepared files manifest's refusal (C8, ruling Q3). `local` writes the prepared manifest create-only under the location's `files/g-<op>/` and a replay reads its own bytes back; a manifest already at that key that is not this install's is not the install's to replace, so the landing rolls back (MV-N454, the sentence `carlos move status` shows, beside MV-N180 for an occupied files directory) and a dirty return goes stuck (MV-N456, beside MV-N307). MV-N457 to MV-N459 are reserved and unused. |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-N454 | internal/provision/provision.go | the destination already holds a files manifest this move did not write | pending | |
| +| MV-N456 | internal/provision/provision.go | the source already holds a files manifest the return did not write | pending | |
diff --git a/internal/host/movesinstall.go b/internal/host/movesinstall.go| index 962d4ed2b..b835be64a 100644 |
| --- a/internal/host/movesinstall.go |
| +++ b/internal/host/movesinstall.go |
| @@ -14,6 +14,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/lease" |
| @@ -89,11 +90,12 @@ type installStamp struct { |
| type installRefusal int |
| const ( |
| - refuseFilesOccupied installRefusal = iota + 1 // local: the files paths hold what the install did not write |
| - refuseInstallMismatch // configured: the build does not verify |
| - refuseConfigChanged // configured: a landing's render is not its pin |
| - refuseDeadline // configured: a landing ran past its deadline |
| - refuseUnknownStamp // local: a return found paths under a stamp it cannot account for |
| + refuseFilesOccupied installRefusal = iota + 1 // local: the files paths hold what the install did not write |
| + refuseInstallMismatch // configured: the build does not verify |
| + refuseConfigChanged // configured: a landing's render is not its pin |
| + refuseDeadline // configured: a landing ran past its deadline |
| + refuseUnknownStamp // local: a return found paths under a stamp it cannot account for |
| + refuseManifestOccupied // local: the prepared files manifest's key holds a manifest the install did not write |
| ) |
| // installSource is one kind of install: what the six phases need from the |
| @@ -235,6 +237,15 @@ func newInstallRun(s release.Store, w *movesWorker, rec provision.Record, src in |
| func (ir *installRun) cfg() moveCfg { return ir.w.cfg } |
| +// filesManifestBuilt reports whether the journal records the prepared |
| +// files manifest as built: the step done with a binding, not the "skipped" |
| +// value journals written before C8 existed, which a replay builds (ruling |
| +// Q3 item 2; the step itself is create-only and replay-safe). |
| +func filesManifestBuilt(j *moves.Journal) bool { |
| + v, ok := j.Steps[landStepFilesManifest] |
| + return ok && v != landingFilesManifestSkipped |
| +} |
| + |
| // fault is the test seam after a side effect (the landing's points). |
| func (ir *installRun) fault(point string) error { |
| if f := ir.w.cfg.landFault; f != nil { |
| @@ -774,16 +785,46 @@ func (ir *installRun) local_(ctx context.Context) (bool, error) { |
| return false, err |
| } |
| } |
| - if !j.Done(landStepFilesManifest) { |
| - // Spec rev 7.22 §7.6 (L17, R19-1): the prepared files manifest under |
| - // files/g-<generation>/ in the location, bound to {location, |
| - // g-<generation>, op, stamp}. Its build (C8, files.BuildPrepared) is |
| - // not on main; until it is, the step is recorded as skipped and the |
| - // probe's admission establishes the files baseline (Plan 3). |
| - log.Printf("moves: %s: %s", host, landingFilesManifestSkipped) |
| - if err := mark(landStepFilesManifest, landingFilesManifestSkipped); err != nil { |
| + if !filesManifestBuilt(j) { |
| + // Spec rev 7.22 §7.6 (L17, R19-1; C8, Task 6): the prepared files |
| + // manifest under files/g-<generation>/ in the location, bound to |
| + // {location, g-<generation>, op, stamp}, where the generation is the |
| + // installation's prepared destination generation (the move id, or |
| + // the rollback id of a dirty return), never holder.generation. It |
| + // carries the placed files, <host>.env and the wake state the |
| + // install put in place above, as create-only objects and one |
| + // create-only sequence-0 manifest through the box's own versioned |
| + // store, after the claim sync and stamp capture above (op.exec's |
| + // installation authority). The binding is journalled with the |
| + // manifest's digest; a replay reuses the objects by digest and reads |
| + // the manifest back by key. A journal that records the step as |
| + // skipped (written before C8 existed) counts as not done (ruling Q3 |
| + // item 2). Another manifest at the key is not this install's to |
| + // replace: the refusal (RollbackManifestOccupied). |
| + rows, err := ir.sealedTimers(ctx, seal, src.TimersSHA256) |
| + if err != nil { |
| + return false, err |
| + } |
| + vs, err := ir.versioned(ctx) |
| + if err != nil { |
| + return false, err |
| + } |
| + pm, err := buildPreparedManifest(ctx, vs, cfg.OwnBucket, holder.Location, gen, id, captured.Stamp, dir, envPath, hostfiles.WakeStateOf(host, rows, src.NextDue, src.Replication)) |
| + if errors.Is(err, hostfiles.ErrManifestExists) { |
| + log.Printf("moves: %s: the prepared files manifest of %s is not this install's: %v", host, id, err) |
| + return ir.refuse(ctx, refuseManifestOccupied) |
| + } |
| + if err != nil { |
| + return false, fmt.Errorf("build the prepared files manifest: %w", err) |
| + } |
| + raw, err := json.Marshal(pm) |
| + if err != nil { |
| + return false, err |
| + } |
| + if err := mark(landStepFilesManifest, string(raw)); err != nil { |
| return false, err |
| } |
| + log.Printf("moves: %s: the prepared files manifest %s is written (sha256 %s)", host, pm.Key, pm.SHA256) |
| } |
| if err := ir.fault("before-files-gen"); err != nil { |
| return false, err |
| @@ -1324,6 +1365,8 @@ func recoveryInstallSource(w *movesWorker, s release.Store, rec provision.Record |
| switch why { |
| case refuseFilesOccupied: |
| reason = provision.StuckReturnFilesOccupied |
| + case refuseManifestOccupied: |
| + reason = provision.StuckReturnManifestOccupied |
| case refuseInstallMismatch: |
| reason = provision.StuckReturnInstallMismatch |
| case refuseUnknownStamp: |
diff --git a/internal/host/movesland.go b/internal/host/movesland.go| index 1cb1e4f4b..bb5fcfd12 100644 |
| --- a/internal/host/movesland.go |
| +++ b/internal/host/movesland.go |
| @@ -112,7 +112,7 @@ const ( |
| landStepTimers = "timers" // the timers table is the sealed snapshot |
| landStepColumns = "columns" // replication and next_due carried over |
| landStepVerify = "verify" // the restore-verification entry is gone |
| - landStepFilesManifest = "files_manifest" // the prepared files manifest (C8; skipped until it exists) |
| + landStepFilesManifest = "files_manifest" // the prepared files manifest's binding (JSON preparedManifest: key, sha256, op, stamp; C8, Task 6) |
| landStepFilesGen = "files_gen" // .files-gen written |
| landStepPreInstall = "pre_install" // the database's siblings absent before its install (JSON preInstall) |
| landStepInventoryDB = "inventory_db" // the installed database and its marker are registered under F (carry (b)) |
| @@ -120,8 +120,10 @@ const ( |
| landStepRendered = "rendered" // the held render's file, "sha256:<hex>", journalled even when its ack failed (carry (c)) |
| ) |
| -// landingFilesManifestSkipped is what the files_manifest step records while |
| -// C8 (the prepared-manifest build, region HA spec 1 §5.8) is not on main. |
| +// landingFilesManifestSkipped is what the files_manifest step recorded |
| +// before C8 (the prepared-manifest build, Task 6) existed. Nothing writes |
| +// it now; a journal that holds it counts as not done (filesManifestBuilt), |
| +// so a replay builds the manifest (ruling Q3 item 2). |
| const landingFilesManifestSkipped = "skipped: the prepared files manifest needs C8 (files.BuildPrepared)" |
| // landingRun is one pass's state for one landing record. |
| @@ -665,6 +667,8 @@ func (l *landingRun) installSource() installSource { |
| switch why { |
| case refuseFilesOccupied: |
| reason = provision.RollbackFilesOccupied |
| + case refuseManifestOccupied: |
| + reason = provision.RollbackManifestOccupied |
| case refuseInstallMismatch: |
| reason = provision.RollbackInstallMismatch |
| case refuseConfigChanged: |
diff --git a/internal/host/movesland_test.go b/internal/host/movesland_test.go| index 00294e56b..51fede800 100644 |
| --- a/internal/host/movesland_test.go |
| +++ b/internal/host/movesland_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "errors" |
| "fmt" |
| "io" |
| + "io/fs" |
| "log/slog" |
| "os" |
| "path/filepath" |
| @@ -23,6 +24,7 @@ import ( |
| "github.com/carlosframework/platform/internal/dbverify" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/lease" |
| @@ -1268,25 +1270,196 @@ func TestLandingReplaysArePhaseIdempotent(t *testing.T) { |
| } |
| } |
| -// The prepared files manifest (spec rev 7.22 §7.6, R19-1) needs C8, which |
| -// is not on main: local records the step as skipped and the landing goes |
| -// on. When C8 lands this test becomes TestLocalBindsThePreparedManifestTo |
| -// TheNewGeneration: the manifest key files/g-<op>/manifest.json, bound to |
| -// g-<op> while holder.generation is still the source's. |
| -func TestLocalRecordsThePreparedManifestAsSkippedUntilC8(t *testing.T) { |
| +// landedPreparedManifest reads the prepared manifest the landing's local |
| +// phase journalled (Task 6, C8): the binding from the op journal, the |
| +// manifest at its key in the target's bucket, decoded. |
| +func landedPreparedManifest(t *testing.T, h *landingHarness) (preparedManifest, hostfiles.Manifest, string) { |
| + t.Helper() |
| + raw, ok := h.landJournal(t).Steps[landStepFilesManifest] |
| + if !ok || raw == landingFilesManifestSkipped { |
| + t.Fatalf("files_manifest: %q", raw) |
| + } |
| + var pm preparedManifest |
| + if err := json.Unmarshal([]byte(raw), &pm); err != nil { |
| + t.Fatalf("files_manifest %q: %v", raw, err) |
| + } |
| + obj, ok := h.f.buckets["data-uk"][pm.Key] |
| + if !ok { |
| + t.Fatalf("no manifest at %s in the target's bucket", pm.Key) |
| + } |
| + if got := sha([]byte(obj.body)); got != pm.SHA256 { |
| + t.Fatalf("the manifest at %s is not the journalled bytes: %s", pm.Key, got) |
| + } |
| + var m hostfiles.Manifest |
| + if err := json.Unmarshal([]byte(obj.body), &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + return pm, m, obj.body |
| +} |
| + |
| +// The prepared files manifest (spec rev 7.22 §7.6, R19-1; C8): local |
| +// writes it under files/g-<op>/ in the location, bound to {location, |
| +// g-<op>, op, stamp} in the op journal, at sequence 0, while |
| +// holder.generation is still the source's. It carries the placed files, |
| +// <host>.env and the wake state the landing installed. |
| +func TestLocalBindsThePreparedManifestToTheNewGeneration(t *testing.T) { |
| h := newLandingHarness(t) |
| h.flip(t) |
| h.land(t) |
| h.installed(t) |
| + c := h.claim(t) |
| + if c.Holder.Generation != "cur" { |
| + t.Fatalf("holder.generation moved before the probe: %q", c.Holder.Generation) |
| + } |
| + pm, m, _ := landedPreparedManifest(t, h) |
| + prefix := movesFilesPrefix(c.Holder.Location, p1ID) |
| + if pm.Key != hostfiles.ManifestKey(prefix) || pm.Op != p1ID || pm.Stamp != c.Holder.Stamp { |
| + t.Fatalf("binding: %+v, want key %s op %s stamp %d", pm, hostfiles.ManifestKey(prefix), p1ID, c.Holder.Stamp) |
| + } |
| + if m.Seq != 0 { |
| + t.Fatalf("prepared manifest at seq %d", m.Seq) |
| + } |
| + if err := hostfiles.ValidateManifest(m, []string{prefix}); err != nil { |
| + t.Fatal(err) |
| + } |
| + dir := filepath.Join(h.tdir, p1Host) |
| + placed := 0 |
| + if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { |
| + if err != nil || d.IsDir() { |
| + return err |
| + } |
| + rel, _ := filepath.Rel(dir, path) |
| + if rel == archive.MarkerName { |
| + return nil // .files-gen is written after the manifest, so it is not in it |
| + } |
| + body, err := os.ReadFile(path) |
| + if err != nil { |
| + return err |
| + } |
| + placed++ |
| + if e := m.Entries[filepath.ToSlash(rel)]; e.SHA256 != sha(body) || e.Seq != 0 { |
| + t.Errorf("%s: %+v", rel, e) |
| + } |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if placed == 0 || len(m.Entries) != placed { |
| + t.Fatalf("%d entries for %d placed files: %+v", len(m.Entries), placed, m.Entries) |
| + } |
| + env, err := os.ReadFile(filepath.Join(h.tdir, p1Host+".env")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if e := m.Platform[hostfiles.EnvItem]; e.SHA256 != sha(env) { |
| + t.Fatalf("env item %+v is not <host>.env", e) |
| + } |
| + wobj, ok := h.f.buckets["data-uk"][m.Platform[hostfiles.WakeItem].Object] |
| + if !ok { |
| + t.Fatal("no wake state object") |
| + } |
| + var ws hostfiles.WakeState |
| + if err := json.Unmarshal([]byte(wobj.body), &ws); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ws.V != 1 || ws.Host != p1Host || len(h.replaced) != 1 || len(ws.Timers) != len(h.replaced[0]) || ws.NextDue != h.nextDue[0] || ws.Replication != h.repl[0] { |
| + t.Fatalf("wake state %+v, timers replaced %v, next_due %v, replication %v", ws, h.replaced, h.nextDue, h.repl) |
| + } |
| +} |
| + |
| +// A journal that records the step as skipped (written before C8 existed) |
| +// counts as not done (ruling Q3 item 2): a replay builds the manifest and |
| +// the landing goes on with the binding in its journal. |
| +func TestSkippedManifestStepIsBuiltOnReplay(t *testing.T) { |
| + h := newLandingHarness(t) |
| + h.flip(t) |
| + h.landAt = "after-verify" |
| + h.crashLand(t) |
| + j := h.landJournal(t) |
| + j.Mark(landStepFilesManifest, landingFilesManifestSkipped) |
| + if err := j.Commit(h.tdir); err != nil { |
| + t.Fatal(err) |
| + } |
| if got := h.landJournal(t).Steps[landStepFilesManifest]; got != landingFilesManifestSkipped { |
| - t.Fatalf("files_manifest: %q", got) |
| + t.Fatalf("the skip was not planted: %q", got) |
| } |
| - if c := h.claim(t); c.Holder.Generation != "cur" { |
| - t.Fatalf("holder.generation moved before the probe: %q", c.Holder.Generation) |
| + h.freshTarget() |
| + h.land(t) |
| + h.installed(t) |
| + pm, m, _ := landedPreparedManifest(t, h) |
| + if pm.Op != p1ID || m.Seq != 0 { |
| + t.Fatalf("replay did not build the manifest: %+v seq %d", pm, m.Seq) |
| + } |
| +} |
| + |
| +// A cross-region landing's prepared manifest names destination-local |
| +// objects it materialized from the sealed archive (claim P6-D3): every |
| +// object lies under the manifest's own prefix in the target's bucket, and |
| +// nothing is written under the source's files prefix. |
| +func TestCrossRegionLandingManifestNamesDestinationObjects(t *testing.T) { |
| + h := newLandingHarness(t) |
| + h.flip(t) |
| + h.land(t) |
| + h.installed(t) |
| + c := h.claim(t) |
| + pm, m, _ := landedPreparedManifest(t, h) |
| + prefix := movesFilesPrefix(c.Holder.Location, p1ID) |
| + if !strings.HasPrefix(pm.Key, prefix) { |
| + t.Fatalf("manifest key %s is not under %s", pm.Key, prefix) |
| + } |
| + for _, entries := range []map[string]hostfiles.Entry{m.Entries, m.Platform} { |
| + for name, e := range entries { |
| + if !strings.HasPrefix(e.Object, prefix+"objects/") { |
| + t.Fatalf("%s names %s, outside the destination prefix %s", name, e.Object, prefix) |
| + } |
| + obj, ok := h.f.buckets["data-uk"][e.Object] |
| + if !ok || sha([]byte(obj.body)) != e.SHA256 { |
| + t.Fatalf("%s names %s, which the target's bucket does not hold at that digest", name, e.Object) |
| + } |
| + } |
| + } |
| + for k := range h.f.buckets["data-ie"] { |
| + if strings.Contains(k, "/files/") { |
| + t.Fatalf("the landing wrote into the source region's files: %s", k) |
| + } |
| + } |
| +} |
| + |
| +// A files manifest already at P that is not this install's (another |
| +// writer's, or a damaged one) is not the landing's to replace: local |
| +// refuses and the move rolls back (RollbackManifestOccupied), as it does |
| +// for an occupied files directory. |
| +func TestLocalRefusesAForeignPreparedManifest(t *testing.T) { |
| + h := newLandingHarness(t) |
| + h.flip(t) |
| + c := h.claim(t) |
| + key := hostfiles.ManifestKey(movesFilesPrefix(c.Holder.Location, p1ID)) |
| + body := `{"v":2,"seq":0,"entries":{"x.txt":{"object":"` + movesFilesPrefix(c.Holder.Location, p1ID) + `objects/0000000000000000-` + sha([]byte("x")) + `","sha256":"` + sha([]byte("x")) + `","size":1,"seq":0}},"platform":{}}` |
| + h.f.store("data-uk", key, fakeMoveObject{lease.MirrorObject{ETag: "foreign", Size: int64(len(body))}, body}) |
| + h.f.setVersion("data-uk", key, h.f.nextVersion()) |
| + h.land(t) |
| + if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackManifestOccupied || m.LandingPhase != provision.LandLocal { |
| + t.Fatalf("not refused for %q at local: %+v", provision.RollbackManifestOccupied, m) |
| + } |
| + if got := h.f.buckets["data-uk"][key].body; got != body { |
| + t.Fatal("the foreign manifest was replaced") |
| } |
| + if _, ok := h.landJournal(t).Steps[landStepFilesManifest]; ok { |
| + t.Fatal("the refused step was journalled as done") |
| + } |
| +} |
| + |
| +// Nothing is written under the location's files prefix before the |
| +// files_manifest step: the manifest follows the placed files, the env |
| +// file, the timers and the columns (the step order of local, §7.6). |
| +func TestLocalWritesNoFilesManifestBeforeItsStep(t *testing.T) { |
| + h := newLandingHarness(t) |
| + h.flip(t) |
| + h.landAt = "after-verify" |
| + h.crashLand(t) |
| for k := range h.f.buckets["data-uk"] { |
| if strings.HasPrefix(k, cutRoot+"/files/") { |
| - t.Fatalf("a files manifest was written without C8: %s", k) |
| + t.Fatalf("a files manifest was written before the files_manifest step: %s", k) |
| } |
| } |
| } |
diff --git a/internal/hostfiles/wake.go b/internal/hostfiles/wake.go| index e603ca023..fe9bc5d1f 100644 |
| --- a/internal/hostfiles/wake.go |
| +++ b/internal/hostfiles/wake.go |
| @@ -56,7 +56,16 @@ func ExportWake(reg *registry.Registry, host string) (WakeState, error) { |
| if err != nil { |
| return WakeState{}, err |
| } |
| - ws := WakeState{V: 1, Host: host, Timers: []WakeTimer{}, NextDue: rt.NextDue, Replication: rt.Replication} |
| + return WakeStateOf(host, rows, rt.NextDue, rt.Replication), nil |
| +} |
| + |
| +// WakeStateOf is host's wake state from its timer rows, next wake and |
| +// replication class, as ExportWake reads them from the registry: an |
| +// installation that holds the rows it is about to install (a move's |
| +// sealed timers snapshot) builds its prepared manifest's wake state with |
| +// it. A finished once-timer is left out, as ExportWake leaves it out. |
| +func WakeStateOf(host string, rows []registry.Timer, nextDue int64, replication string) WakeState { |
| + ws := WakeState{V: 1, Host: host, Timers: []WakeTimer{}, NextDue: nextDue, Replication: replication} |
| for _, t := range rows { |
| if t.Kind == "once" && t.NextAt == 0 { |
| continue |
| @@ -65,7 +74,7 @@ func ExportWake(reg *registry.Registry, host string) (WakeState, error) { |
| NextAt: t.NextAt, LastAt: t.LastAt, LastStatus: t.LastStatus, LastMs: t.LastMs, Attempts: t.Attempts, |
| CreatedAt: t.CreatedAt, DueAt: t.DueAt, Registration: t.Registration}) |
| } |
| - return ws, nil |
| + return ws |
| } |
| // ImportWake makes host's wake state exactly ws. It validates everything |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index b224cf4de..6a02fbf91 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -1675,6 +1675,12 @@ const ( |
| // installation registered them, so nothing retired them). They are not |
| // the landing's to clear (final review, finding 1). |
| RollbackFilesOccupied = "the files directory already holds files this move did not write" |
| + // RollbackManifestOccupied: the prepared files manifest's key under the |
| + // location already holds a manifest this landing did not write (moves |
| + // Task 6, C8). The prepared manifest is create-only and the landing's |
| + // replay reads its own bytes back; anything else there is not the |
| + // landing's to replace. |
| + RollbackManifestOccupied = "the destination already holds a files manifest this move did not write" |
| // RollbackProbeAttempts: the probe used every attempt it was given |
| // without the app answering (§7.7). |
| RollbackProbeAttempts = "the probe ran out of attempts" |
| @@ -1719,6 +1725,8 @@ func MoveMarkRollback(g MoveGuard, reason string) func(*Record) error { |
| rec.Move.Reason = RollbackInstallMismatch |
| case RollbackFilesOccupied: |
| rec.Move.Reason = RollbackFilesOccupied |
| + case RollbackManifestOccupied: |
| + rec.Move.Reason = RollbackManifestOccupied |
| case RollbackProbeAttempts: |
| rec.Move.Reason = RollbackProbeAttempts |
| case RollbackCommitDeadline: |
| @@ -1932,6 +1940,11 @@ const ( |
| // did not write after the source's own pre-move files were parked. A |
| // rollback whose source is at local. |
| StuckReturnFilesOccupied = "the files directory holds files the return did not write" |
| + // StuckReturnManifestOccupied: a dirty return's install found the |
| + // prepared files manifest's key under the source's location holding a |
| + // manifest it did not write (moves Task 6, C8). A rollback whose source |
| + // is at local. |
| + StuckReturnManifestOccupied = "the source already holds a files manifest the return did not write" |
| // StuckReturnInstallMismatch: the database a dirty return built from its |
| // lane does not verify. A rollback whose source is at configured, where |
| // the database is installed. |
| @@ -2034,6 +2047,8 @@ func MoveRollBack(g MoveGuard, rbID, reason string, now time.Time) func(*Record) |
| rb.Reason = RollbackInstallMismatch |
| case RollbackFilesOccupied: |
| rb.Reason = RollbackFilesOccupied |
| + case RollbackManifestOccupied: |
| + rb.Reason = RollbackManifestOccupied |
| case RollbackProbeAttempts: |
| rb.Reason = RollbackProbeAttempts |
| case RollbackCommitDeadline: |
| @@ -2061,7 +2076,7 @@ func MoveRollBack(g MoveGuard, rbID, reason string, now time.Time) func(*Record) |
| func recordedRollbackReason(r string) bool { |
| switch r { |
| case RollbackConfigChanged, RollbackLandingDeadline, RollbackClaimWired, RollbackLandingLayout, RollbackInstallMismatch, |
| - RollbackFilesOccupied, RollbackProbeAttempts, RollbackCommitDeadline, RollbackDeclaredLost, RollbackTargetFenced, RollbackProbeUnpriced: |
| + RollbackFilesOccupied, RollbackManifestOccupied, RollbackProbeAttempts, RollbackCommitDeadline, RollbackDeclaredLost, RollbackTargetFenced, RollbackProbeUnpriced: |
| return true |
| } |
| return false |
| @@ -2200,9 +2215,9 @@ func MoveRecordRecoveryInputs(g MoveGuard, rbID string, in MoveInputs) func(*Rec |
| // verified (a clean return's verification, INV-4), or a revert (its |
| // verify); |
| // - StuckRecoveryProbeAttempts: a rollback whose source is at probing; |
| -// - StuckReturnFilesOccupied and StuckReturnUnknownStamp: a dirty |
| -// rollback whose source is at local (the shared installer, Plan 4 |
| -// Task 3); |
| +// - StuckReturnFilesOccupied, StuckReturnManifestOccupied and |
| +// StuckReturnUnknownStamp: a dirty rollback whose source is at local |
| +// (the shared installer, Plan 4 Task 3; the prepared manifest, Task 6); |
| // - StuckReturnInstallMismatch: a dirty rollback whose source is at |
| // configured; |
| // - StuckReturnTargetDatabase, StuckReturnTargetChanged and |
| @@ -2238,6 +2253,8 @@ func MoveMarkStuck(g MoveGuard, reason string) func(*Record) error { |
| why = StuckRevertProbeBudget |
| case StuckReturnFilesOccupied: |
| why = StuckReturnFilesOccupied |
| + case StuckReturnManifestOccupied: |
| + why = StuckReturnManifestOccupied |
| case StuckReturnInstallMismatch: |
| why = StuckReturnInstallMismatch |
| case StuckReturnUnknownStamp: |
| @@ -2265,7 +2282,7 @@ func MoveMarkStuck(g MoveGuard, reason string) func(*Record) error { |
| ok = rb.SourcePhase == RbSourceRestoring || rb.SourcePhase == RbSourceVerified |
| case StuckRecoveryProbeAttempts: |
| ok = rb.SourcePhase == RbSourceProbing |
| - case StuckReturnFilesOccupied, StuckReturnUnknownStamp: |
| + case StuckReturnFilesOccupied, StuckReturnManifestOccupied, StuckReturnUnknownStamp: |
| ok = rb.Dirty && rb.SourcePhase == RbSourceLocal |
| case StuckReturnInstallMismatch: |
| ok = rb.Dirty && rb.SourcePhase == RbSourceConfigured |