carlos move status: a recorded refusal replaces "Ready to cut"
Fix round 1, item 2. While a refusal is recorded the move is unpinned and the door refuses a cut, so status prints "The cut did not start: <reason>." and not M-C12; a manual move still exits waiting. For that to hold only while the refusal does, MoveGuardedRepin now clears Move.Reason: a move is pinned again only once the cause is gone (the source's repin guard, the target's T13; a scoped holder is never repinned), so from then on it is ready to cut and status says M-C12 again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+40
−7
internal/provision/move_test.go+16 −0internal/provision/provision.go+4 −0move.go+11 −3move_test.go+9 −4
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go| index a118a429f..c748c4a49 100644 |
| --- a/internal/provision/move_test.go |
| +++ b/internal/provision/move_test.go |
| @@ -603,6 +603,22 @@ func TestBackToReadyRecordsTheCutGuardRefusals(t *testing.T) { |
| } |
| } |
| +// Fix round 1: a repin clears a recorded refusal. A move is pinned again |
| +// only once the refusal's cause is gone (decideReady and T13), so from then |
| +// on it is ready to cut and `carlos move status` no longer says the cut did |
| +// not start. |
| +func TestRepinClearsARecordedRefusal(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Now() |
| + rec := &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveCutting, CutPhase: CutRequested, CutAt: &now, Inputs: &MoveInputs{Release: "v1"}}} |
| + if err := MoveBackToReadyRefused(g, RefusedSourceUnarmed)(rec); err != nil || rec.Move.Reason != RefusedSourceUnarmed { |
| + t.Fatalf("fixture: %v %+v", err, rec.Move) |
| + } |
| + if err := MoveGuardedRepin(g, MoveInputs{Release: "v2"})(rec); err != nil || rec.Move.Inputs == nil || rec.Move.Reason != "" { |
| + t.Fatalf("the repin kept the refusal: %v %+v", err, rec.Move) |
| + } |
| +} |
| + |
| // The landing marks a rollback it cannot yet start (Plan 2 Task 10): the |
| // reason is written on the landing record, the state and phase stay as they |
| // are, and Plan 4's rollback transition is the one call site that replaces |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 1e41b5a05..65a56288b 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -1362,6 +1362,10 @@ func MoveGuardedRepin(g MoveGuard, in MoveInputs) func(*Record) error { |
| return fmt.Errorf("%w: inputs are re-pinned only on a ready move that has none", ErrMoveConflict) |
| } |
| rec.Move.Inputs = pinInputs(in) |
| + // A refusal MoveBackToReadyRefused recorded ends here (fix round |
| + // 1): a move is pinned again only once its cause is gone, and from |
| + // then on `carlos move status` says it is ready to cut. |
| + rec.Move.Reason = "" |
| return nil |
| } |
| } |
diff --git a/move.go b/move.go| index 6356dbff4..6f4c67a7d 100644 |
| --- a/move.go |
| +++ b/move.go |
| @@ -721,9 +721,17 @@ func (w *moveWatch) observe(a moveAnswer) int { |
| } |
| case "ready": |
| // A cut the source refused (the cut guard, a replica layout it |
| - // cannot cut) is back at ready with the reason recorded. |
| - if j.Reason != "" && w.first("refused:"+j.Reason) { |
| - moveSay(w.m.out, fmt.Sprintf("The cut did not start: %s.", j.Reason)) |
| + // cannot cut) is back at ready with the reason recorded, unpinned |
| + // until the cause is gone and a repin clears the reason. Until then |
| + // it is not ready to cut, so M-C12 is not said (fix round 1). |
| + if j.Reason != "" { |
| + if w.first("refused:" + j.Reason) { |
| + moveSay(w.m.out, fmt.Sprintf("The cut did not start: %s.", j.Reason)) |
| + } |
| + if j.Cut == "manual" { |
| + return moveExitWaiting |
| + } |
| + break |
| } |
| if j.Cut == "manual" { |
| moveSay(w.m.out, fmt.Sprintf("Ready to cut. Run carlos move cut --host %s when you are ready.", host)) |
diff --git a/move_test.go b/move_test.go| index 27f1be224..e2f3a7390 100644 |
| --- a/move_test.go |
| +++ b/move_test.go |
| @@ -1074,7 +1074,9 @@ func TestMoveDeclareLost(t *testing.T) { |
| // The cut guard (Paul's ruling, 2026-10-11): a cut the source refused goes |
| // back to ready with the reason recorded on the move, and `carlos move |
| -// status` says it. A ready move with no reason says nothing more. |
| +// status` says it. While the refusal is recorded (until a repin clears it) |
| +// the move is not ready to cut, so M-C12 is not said (fix round 1). A ready |
| +// move with no reason says M-C12 alone. |
| func TestMoveStatusSaysWhyTheCutDidNotStart(t *testing.T) { |
| for _, reason := range []string{provision.RefusedTargetUnarmed, provision.RefusedSourceUnarmed, provision.RefusedReplicaLayout} { |
| c := &fakeMoveConsole{frames: []frame{inFlight("ready", frame{"cut": "manual", "reason": reason}, nil)}} |
| @@ -1082,10 +1084,13 @@ func TestMoveStatusSaysWhyTheCutDidNotStart(t *testing.T) { |
| if code != moveExitWaiting { |
| t.Fatalf("exit %d:\n%s", code, out) |
| } |
| - wantLines(t, out, "Move "+tMoveID+" of acme.go.tito.io is ready.", "The cut did not start: "+reason+".", mc12) |
| + wantLines(t, out, "Move "+tMoveID+" of acme.go.tito.io is ready.", "The cut did not start: "+reason+".") |
| + wantNoLine(t, out, mc12) |
| } |
| c := &fakeMoveConsole{frames: []frame{inFlight("ready", frame{"cut": "manual"}, nil)}} |
| - if _, out := runMoveAgainst(t, c, "status", "--host", tHost); strings.Contains(out, "did not start") { |
| - t.Fatalf("a ready move with no refusal:\n%s", out) |
| + code, out := runMoveAgainst(t, c, "status", "--host", tHost) |
| + if code != moveExitWaiting || strings.Contains(out, "did not start") { |
| + t.Fatalf("a ready move with no refusal = %d:\n%s", code, out) |
| } |
| + wantLines(t, out, mc12) |
| } |