carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 1e8e671e2be98a42e536694757d82b97effe9f8c parent 43f8b6e
7 files changed, +287 −34
  • approved_moves_copy_test.go +1 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +7 −0
  • internal/host/movesinstall.go +56 −13
  • internal/host/movesland.go +7 −3
  • internal/host/movesland_test.go +183 −10
  • internal/hostfiles/wake.go +11 −2
  • internal/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