carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 483d2224b2b2e085c484c6c22b7b49645df9d344 parent 84c8d87
4 files changed, +40 −7
  • internal/provision/move_test.go +16 −0
  • internal/provision/provision.go +4 −0
  • move.go +11 −3
  • move_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)
}