carlos / platform Public

provision: the target's three dirty-return stuck reasons (Task 4 fix round 1, ruling 2)

StuckReturnTargetDatabase, StuckReturnTargetChanged and
StuckReturnTargetFiles are legal only on a dirty rollback whose target
is at returning with the source still waiting; the target phase is kept
and MoveRetry returns to it. phase1Conflict speaks them; copy rows
MV-N310 to MV-N312, pending.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3baa8e7bb3abd1c03cec53e1bf6ce67b820b0cd6 parent 395b479
6 files changed, +94 −3
  • approved_moves_copy_test.go +1 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +3 −0
  • internal/console/apiinstancemove.go +6 −1
  • internal/console/apiinstancemove_test.go +2 −1
  • internal/provision/move_test.go +54 −0
  • internal/provision/provision.go +28 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 44086b642..29d2a52fb 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -72,7 +72,7 @@ var movesCopyIDs = []string{
"MV-N175", "MV-N176", "MV-N177", "MV-N178", "MV-N179", "MV-N180", "MV-N181",
"MV-N182", "MV-N183", "MV-N184", "MV-N185", "MV-N186",
"MV-N300", "MV-N301", "MV-N302", "MV-N303", "MV-N304", "MV-N305", "MV-N306",
- "MV-N307", "MV-N308", "MV-N309",
+ "MV-N307", "MV-N308", "MV-N309", "MV-N310", "MV-N311", "MV-N312",
}
// 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 d6559f8b5..75f1ada2f 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -257,6 +257,9 @@ Columns: id, file, text, status.
| MV-N307 | internal/provision/provision.go | the files directory holds files the return did not write | pending |
| MV-N308 | internal/provision/provision.go | the returned database does not match the build | pending |
| MV-N309 | internal/provision/provision.go | the source holds files of an installation the return cannot account for | pending |
+| MV-N310 | internal/provision/provision.go | the target's stopped database is missing | pending |
+| MV-N311 | internal/provision/provision.go | the target changed after it stopped | pending |
+| MV-N312 | internal/provision/provision.go | the target's files cannot be archived | pending |
## Plan 5
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index 3d933d7ca..ac15c5fdf 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -917,7 +917,9 @@ func phase1Conflict(err error) string {
// The reasons Plan 4 records (ruling J9): provision.RollbackDeclaredLost,
// StuckBaselineMismatch, StuckRecoveryProbeAttempts, StuckRevertProbeBudget,
// and the shared installer's StuckReturnFilesOccupied,
- // StuckReturnInstallMismatch and StuckReturnUnknownStamp (Task 3).
+ // StuckReturnInstallMismatch and StuckReturnUnknownStamp (Task 3), and
+ // the target's StuckReturnTargetDatabase, StuckReturnTargetChanged
+ // and StuckReturnTargetFiles (Task 4).
msg == "the target box was declared lost",
msg == "the source no longer matches what it recorded when it stopped",
msg == "the recovery probe ran out of attempts",
@@ -925,6 +927,9 @@ func phase1Conflict(err error) string {
msg == "the files directory holds files the return did not write",
msg == "the returned database does not match the build",
msg == "the source holds files of an installation the return cannot account for",
+ msg == "the target's stopped database is missing",
+ msg == "the target changed after it stopped",
+ msg == "the target's files cannot be archived",
strings.HasPrefix(msg, "the record's move is ") && strings.Contains(msg, " for incarnation "),
strings.HasPrefix(msg, "a move to ") && strings.Contains(msg, " is already "),
strings.HasPrefix(msg, "the instance is already in "),
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go
index feb92b2f4..cf552824b 100644
--- a/internal/console/apiinstancemove_test.go
+++ b/internal/console/apiinstancemove_test.go
@@ -1443,7 +1443,8 @@ func TestPhase1ConflictPassesPlan4Refusals(t *testing.T) {
// Ruling J9 (fix round 1): the four reasons Plan 4 records are spoken as
// their recorded sentences too, not the fixed one.
for _, reason := range []string{provision.RollbackDeclaredLost, provision.StuckBaselineMismatch, provision.StuckRecoveryProbeAttempts, provision.StuckRevertProbeBudget,
- provision.StuckReturnFilesOccupied, provision.StuckReturnInstallMismatch, provision.StuckReturnUnknownStamp} {
+ provision.StuckReturnFilesOccupied, provision.StuckReturnInstallMismatch, provision.StuckReturnUnknownStamp,
+ provision.StuckReturnTargetDatabase, provision.StuckReturnTargetChanged, provision.StuckReturnTargetFiles} {
err := fmt.Errorf("%w: %s", provision.ErrMoveConflict, reason)
if got := phase1Conflict(err); got != reason {
t.Fatalf("phase1Conflict(%v) = %q, want %q", err, got, reason)
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go
index dc6fbdd2d..ab5636b57 100644
--- a/internal/provision/move_test.go
+++ b/internal/provision/move_test.go
@@ -1308,6 +1308,60 @@ func TestStuckReturnInstallReasons(t *testing.T) {
}
}
+// A dirty return the target cannot build (Task 4 fix round 1, ruling 2)
+// stops for an operator: the database is missing, the target changed after
+// it stopped, or its files cannot be archived. Each reason is the target's
+// returning phase's own, on a dirty rollback; the target phase and Dirty
+// stay, the source's phase (waiting) is StuckAt, and a retry goes back to
+// it, so the target runs its return again.
+func TestStuckReturnTargetReasons(t *testing.T) {
+ g := cutTestGuard()
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ at := func(target string, dirty bool) *Record {
+ rec := rollingBackRecord(t, g, now)
+ rec.Move.Rollback.TargetPhase, rec.Move.Rollback.Dirty = target, dirty
+ return rec
+ }
+ reasons := []string{StuckReturnTargetDatabase, StuckReturnTargetChanged, StuckReturnTargetFiles}
+ for _, reason := range reasons {
+ rec := at(RbTargetReturning, true)
+ if err := MoveMarkStuck(g, reason)(rec); err != nil {
+ t.Fatalf("%q: %v", reason, err)
+ }
+ m, rb := rec.Move, rec.Move.Rollback
+ if m.State != MoveStuck || m.StuckReason != reason || rb.StuckAt != RbSourceWaiting || rb.TargetPhase != RbTargetReturning || !rb.Dirty {
+ t.Fatalf("%q: %+v %+v", reason, m, rb)
+ }
+ if err := MoveMarkStuck(g, reason)(rec); err != nil {
+ t.Fatalf("%q replay: %v", reason, err)
+ }
+ if err := MoveRetry(g)(rec); err != nil || m.State != MoveRollingBack || rb.SourcePhase != RbSourceWaiting || rb.TargetPhase != RbTargetReturning || m.StuckReason != "" {
+ t.Fatalf("%q retry: %v %+v %+v", reason, err, m, rb)
+ }
+ }
+ for _, reason := range reasons {
+ for _, c := range []struct {
+ target string
+ dirty bool
+ }{{RbTargetReturning, false}, {RbTargetReleasing, true}, {RbTargetReleased, true}, {RbTargetReturned, true}, {RbTargetCleaning, true}} {
+ rec := at(c.target, c.dirty)
+ if err := MoveMarkStuck(g, reason)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveRollingBack {
+ t.Fatalf("%q at %s (dirty %v): %v", reason, c.target, c.dirty, err)
+ }
+ }
+ // The source's own install phases are not the target's.
+ rec := at(RbTargetReturned, true)
+ rec.Move.Rollback.SourcePhase = RbSourceLocal
+ if err := MoveMarkStuck(g, reason)(rec); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("%q at the source's local: %v", reason, err)
+ }
+ rev := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveReverting, CutPhase: CutStaged, Reason: RevertCutDeadline}}
+ if err := MoveMarkStuck(g, reason)(rev); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("a revert with %q: %v", reason, err)
+ }
+ }
+}
+
func TestRolledBackCloseNeedsAllThreeStamps(t *testing.T) {
g := cutTestGuard()
now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 19f083b42..6a3c3f657 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -1810,6 +1810,22 @@ const (
// another holder's). Nothing is moved. A rollback whose source is at
// local.
StuckReturnUnknownStamp = "the source holds files of an installation the return cannot account for"
+ // The target's dirty return (Plan 4 Task 4, fix round 1, ruling 2):
+ // what the target must send back cannot be built from what it holds,
+ // so no retry by itself can help. Each is a dirty rollback's whose
+ // target is at returning; the target keeps its phase and everything it
+ // holds, and a retry runs the return again.
+ //
+ // StuckReturnTargetDatabase: the stopped database the return must
+ // snapshot is missing or not a database.
+ StuckReturnTargetDatabase = "the target's stopped database is missing"
+ // StuckReturnTargetChanged: the database, its position, the files or
+ // the timers are no longer what the target recorded when it stopped.
+ StuckReturnTargetChanged = "the target changed after it stopped"
+ // StuckReturnTargetFiles: the target's files directory cannot be
+ // archived (a special file, over the cap, too many entries, a path too
+ // long).
+ StuckReturnTargetFiles = "the target's files cannot be archived"
)
// The refusals of this section that reach an operator through a door
@@ -2015,6 +2031,10 @@ func MoveRollbackSourcePhase(g MoveGuard, from, to string, stamp func(*MoveRollb
// Task 3);
// - StuckReturnInstallMismatch: a dirty rollback whose source is at
// configured;
+// - StuckReturnTargetDatabase, StuckReturnTargetChanged and
+// StuckReturnTargetFiles: a dirty rollback whose target is at returning
+// (the source still waiting for it); the target phase is kept, so a
+// retry runs the return again;
// - StuckRevertProbeBudget: a revert.
//
// A rollback records the source phase in StuckAt, which MoveRetry returns
@@ -2042,6 +2062,12 @@ func MoveMarkStuck(g MoveGuard, reason string) func(*Record) error {
why = StuckReturnInstallMismatch
case StuckReturnUnknownStamp:
why = StuckReturnUnknownStamp
+ case StuckReturnTargetDatabase:
+ why = StuckReturnTargetDatabase
+ case StuckReturnTargetChanged:
+ why = StuckReturnTargetChanged
+ case StuckReturnTargetFiles:
+ why = StuckReturnTargetFiles
default:
return fmt.Errorf("provision: stuck reason %q has no recorded sentence", reason)
}
@@ -2061,6 +2087,8 @@ func MoveMarkStuck(g MoveGuard, reason string) func(*Record) error {
ok = rb.Dirty && rb.SourcePhase == RbSourceLocal
case StuckReturnInstallMismatch:
ok = rb.Dirty && rb.SourcePhase == RbSourceConfigured
+ case StuckReturnTargetDatabase, StuckReturnTargetChanged, StuckReturnTargetFiles:
+ ok = rb.Dirty && rb.TargetPhase == RbTargetReturning && rb.SourcePhase == RbSourceWaiting
}
}
case MoveReverting: