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>
6 files changed,
+94
−3
approved_moves_copy_test.go+1 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+3 −0internal/console/apiinstancemove.go+6 −1internal/console/apiinstancemove_test.go+2 −1internal/provision/move_test.go+54 −0internal/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: |