provision: the dirty return install's two stuck reasons (Plan 4 Task 3, ruling J13)
A dirty return's install refuses into stuck, never into a landing's rollback: StuckReturnFilesOccupied at local (the files paths still hold what the return did not write after the source's own pre-move files were parked) and StuckReturnInstallMismatch at configured (the return's build does not verify). MoveMarkStuck takes each at its phase of a dirty rollback only; MoveRetry returns to it. Copy rows MV-N307 and MV-N308 (pending), and phase1Conflict speaks both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+93
−5
approved_moves_copy_test.go+1 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+2 −0internal/console/apiinstancemove.go+8 −4internal/console/apiinstancemove_test.go+2 −1internal/provision/move_test.go+59 −0internal/provision/provision.go+21 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index d6ada9d46..373391082 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -72,6 +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", |
| } |
| // 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 3147cb7f9..fa2369356 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -254,6 +254,8 @@ Columns: id, file, text, status. |
| | MV-N304 | internal/provision/provision.go | the move has committed. it cannot roll back | pending | |
| | MV-N305 | internal/provision/provision.go | the move is not stuck. there is nothing to retry | pending | |
| | MV-N306 | internal/provision/provision.go | the target holds nothing the move still needs. there is nothing to declare lost | pending | |
| +| 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 | |
| ## Plan 5 |
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 35beb3ce1..8b4935397 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -889,8 +889,8 @@ func legacyAbortOf(seen provision.MoveState) func(*provision.Record) error { |
| // phase1Conflict maps a provision transition refusal to its recorded |
| // sentence (copy record MV-N12 to MV-N16, MV-N38 to MV-N46, and Plan 4's |
| -// MV-N300 to MV-N306: the rollback, retry and declare-lost refusals and the |
| -// four rollback and stuck reasons): never the |
| +// MV-N300 to MV-N308: the rollback, retry and declare-lost refusals and the |
| +// rollback and stuck reasons): never the |
| // "provision: move state conflict:" prefix, never an em dash. A refusal |
| // this table does not know answers a fixed sentence, so a new provision |
| // sentence cannot reach an operator unreviewed. |
| @@ -914,12 +914,16 @@ func phase1Conflict(err error) string { |
| msg == "the move has committed. it cannot roll back", |
| msg == "the move is not stuck. there is nothing to retry", |
| msg == "the target holds nothing the move still needs. there is nothing to declare lost", |
| - // The four reasons Plan 4 records (ruling J9): provision.RollbackDeclaredLost, |
| - // StuckBaselineMismatch, StuckRecoveryProbeAttempts and StuckRevertProbeBudget. |
| + // The reasons Plan 4 records (ruling J9): provision.RollbackDeclaredLost, |
| + // StuckBaselineMismatch, StuckRecoveryProbeAttempts, StuckRevertProbeBudget, |
| + // and the shared installer's StuckReturnFilesOccupied and |
| + // StuckReturnInstallMismatch (Task 3). |
| 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", |
| msg == "the source did not answer within 15 minutes of moving back", |
| + msg == "the files directory holds files the return did not write", |
| + msg == "the returned database does not match the build", |
| 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 1985d4943..2a796f07c 100644 |
| --- a/internal/console/apiinstancemove_test.go |
| +++ b/internal/console/apiinstancemove_test.go |
| @@ -1442,7 +1442,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} { |
| + for _, reason := range []string{provision.RollbackDeclaredLost, provision.StuckBaselineMismatch, provision.StuckRecoveryProbeAttempts, provision.StuckRevertProbeBudget, |
| + provision.StuckReturnFilesOccupied, provision.StuckReturnInstallMismatch} { |
| 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 943085db7..3190b682c 100644 |
| --- a/internal/provision/move_test.go |
| +++ b/internal/provision/move_test.go |
| @@ -1245,6 +1245,65 @@ func TestStuckAndRetryReturnToThePhase(t *testing.T) { |
| } |
| } |
| +// The dirty return's install (Plan 4 Task 3, ruling J13) refuses into stuck, |
| +// never into a landing's rollback: the files paths still occupied after the |
| +// source's own pre-move files were parked stop it at local, and a build that |
| +// does not verify stops it at configured (where the database is installed). |
| +// Each reason is its phase's own, for a dirty return only, and a retry goes |
| +// back to that phase. |
| +func TestStuckReturnInstallReasons(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + at := func(phase string, dirty bool) *Record { |
| + rec := rollingBackRecord(t, g, now) |
| + rec.Move.Rollback.TargetPhase, rec.Move.Rollback.SourcePhase, rec.Move.Rollback.Dirty = RbTargetReturned, phase, dirty |
| + return rec |
| + } |
| + for _, c := range []struct{ phase, reason string }{ |
| + {RbSourceLocal, StuckReturnFilesOccupied}, |
| + {RbSourceConfigured, StuckReturnInstallMismatch}, |
| + } { |
| + rec := at(c.phase, true) |
| + if err := MoveMarkStuck(g, c.reason)(rec); err != nil { |
| + t.Fatalf("%s: %v", c.phase, err) |
| + } |
| + m, rb := rec.Move, rec.Move.Rollback |
| + if m.State != MoveStuck || m.StuckReason != c.reason || rb.StuckAt != c.phase || rb.Reason != RollbackProbeAttempts { |
| + t.Fatalf("%s: %+v %+v", c.phase, m, rb) |
| + } |
| + if err := MoveMarkStuck(g, c.reason)(rec); err != nil { |
| + t.Fatalf("%s replay: %v", c.phase, err) |
| + } |
| + if err := MoveRetry(g)(rec); err != nil || m.State != MoveRollingBack || rb.SourcePhase != c.phase || m.StuckReason != "" { |
| + t.Fatalf("%s retry: %v %+v %+v", c.phase, err, m, rb) |
| + } |
| + } |
| + for _, c := range []struct { |
| + phase, reason string |
| + dirty bool |
| + }{ |
| + {RbSourceLocal, StuckReturnFilesOccupied, false}, |
| + {RbSourceConfigured, StuckReturnInstallMismatch, false}, |
| + {RbSourceConfigured, StuckReturnFilesOccupied, true}, |
| + {RbSourceLocal, StuckReturnInstallMismatch, true}, |
| + {RbSourceParking, StuckReturnFilesOccupied, true}, |
| + {RbSourceRestored, StuckReturnInstallMismatch, true}, |
| + {RbSourceVerified, StuckReturnFilesOccupied, false}, |
| + } { |
| + rec := at(c.phase, c.dirty) |
| + if err := MoveMarkStuck(g, c.reason)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.State != MoveRollingBack { |
| + t.Fatalf("%s (dirty %v) with %q: %v", c.phase, c.dirty, c.reason, err) |
| + } |
| + } |
| + // A revert installs nothing, so neither reason is a revert's. |
| + rev := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveReverting, CutPhase: CutStaged, Reason: RevertCutDeadline}} |
| + for _, reason := range []string{StuckReturnFilesOccupied, StuckReturnInstallMismatch} { |
| + if err := MoveMarkStuck(g, reason)(rev); !errors.Is(err, ErrMoveConflict) || rev.Move.State != MoveReverting { |
| + 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 224100466..ef808c876 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -1795,6 +1795,15 @@ const ( |
| StuckRecoveryProbeAttempts = "the recovery probe ran out of attempts" |
| // StuckRevertProbeBudget: a revert's probes failed for 15 minutes. |
| StuckRevertProbeBudget = "the source did not answer within 15 minutes of moving back" |
| + // StuckReturnFilesOccupied: a dirty return's install (Plan 4 Task 3, |
| + // ruling J13) found the files directory or <host>.env holding what it |
| + // 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" |
| + // 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. |
| + StuckReturnInstallMismatch = "the returned database does not match the build" |
| ) |
| // The refusals of this section that reach an operator through a door |
| @@ -1995,6 +2004,10 @@ func MoveRollbackSourcePhase(g MoveGuard, from, to string, stamp func(*MoveRollb |
| // verified (a clean return's verification, INV-4), or a revert (its |
| // verify); |
| // - StuckRecoveryProbeAttempts: a rollback whose source is at probing; |
| +// - StuckReturnFilesOccupied: a dirty rollback whose source is at local |
| +// (the shared installer, Plan 4 Task 3); |
| +// - StuckReturnInstallMismatch: a dirty rollback whose source is at |
| +// configured; |
| // - StuckRevertProbeBudget: a revert. |
| // |
| // A rollback records the source phase in StuckAt, which MoveRetry returns |
| @@ -2016,6 +2029,10 @@ func MoveMarkStuck(g MoveGuard, reason string) func(*Record) error { |
| why = StuckRecoveryProbeAttempts |
| case StuckRevertProbeBudget: |
| why = StuckRevertProbeBudget |
| + case StuckReturnFilesOccupied: |
| + why = StuckReturnFilesOccupied |
| + case StuckReturnInstallMismatch: |
| + why = StuckReturnInstallMismatch |
| default: |
| return fmt.Errorf("provision: stuck reason %q has no recorded sentence", reason) |
| } |
| @@ -2031,6 +2048,10 @@ func MoveMarkStuck(g MoveGuard, reason string) func(*Record) error { |
| ok = rb.SourcePhase == RbSourceRestoring || rb.SourcePhase == RbSourceVerified |
| case StuckRecoveryProbeAttempts: |
| ok = rb.SourcePhase == RbSourceProbing |
| + case StuckReturnFilesOccupied: |
| + ok = rb.Dirty && rb.SourcePhase == RbSourceLocal |
| + case StuckReturnInstallMismatch: |
| + ok = rb.Dirty && rb.SourcePhase == RbSourceConfigured |
| } |
| } |
| case MoveReverting: |