carlos move status says why a cut did not start
A ready move with a recorded refusal (the cut guard's two, and the replica layout's MV-N167, which status never printed) now prints "The cut did not start: <reason>." before M-C12. Copy row MV-N443, pending.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+27
−2
approved_moves_copy_test.go+1 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+2 −1move.go+5 −0move_test.go+19 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 7553d9484..1a3e9dc0f 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -84,7 +84,7 @@ var movesCopyIDs = []string{ |
| "MV-N372", "MV-N373", "MV-N374", "MV-N375", "MV-N376", "MV-N377", "MV-N378", "MV-N379", |
| "MV-N380", "MV-N381", |
| "MV-N385", "MV-N386", "MV-N387", "MV-N389", "MV-N390", |
| - "MV-N440", "MV-N441", "MV-N442", |
| + "MV-N440", "MV-N441", "MV-N442", "MV-N443", |
| } |
| // 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 1869b4465..4b4977d97 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -398,10 +398,11 @@ The 16 rows added after that review (MV-N184 to MV-N186, MV-N300 to MV-N312) wer |
| ## Moves cut guard |
| -Paul's ruling of 2026-10-11 (N1.3 in the Plan 7 preflight): the source refuses to start a cut unless the target's latest report states T13, that its box can land the move and roll it back, and unless the source can recover the move itself. MV-N440 is T13's reason, which `carlos move status` shows through M-C4 while the move is preparing. MV-N441 and MV-N442 are the refusals the cut records on the move (MoveBackToReadyRefused, like MV-N167). MV-N443 to MV-N449 are not used. |
| +Paul's ruling of 2026-10-11 (N1.3 in the Plan 7 preflight): the source refuses to start a cut unless the target's latest report states T13, that its box can land the move and roll it back, and unless the source can recover the move itself. MV-N440 is T13's reason, which `carlos move status` shows through M-C4 while the move is preparing. MV-N441 and MV-N442 are the refusals the cut records on the move (MoveBackToReadyRefused, like MV-N167). MV-N443 is the line `carlos move status` now prints for a ready move with a recorded refusal, MV-N167 included. MV-N444 to MV-N449 are not used. |
| | id | file | text | status | |
| |---|---|---|---| |
| | MV-N440 | internal/host/movesreport.go | this box cannot land the move or roll it back yet | pending | |
| | MV-N441 | internal/provision/provision.go | the target box cannot land the move or roll it back yet | pending | |
| | MV-N442 | internal/provision/provision.go | the source box cannot recover the move if the cut fails | pending | |
| +| MV-N443 | move.go | The cut did not start: <reason>. | pending | |
diff --git a/move.go b/move.go| index 670426511..6356dbff4 100644 |
| --- a/move.go |
| +++ b/move.go |
| @@ -720,6 +720,11 @@ 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)) |
| + } |
| if j.Cut == "manual" { |
| moveSay(w.m.out, fmt.Sprintf("Ready to cut. Run carlos move cut --host %s when you are ready.", host)) |
| return moveExitWaiting |
diff --git a/move_test.go b/move_test.go| index 18848be6b..27f1be224 100644 |
| --- a/move_test.go |
| +++ b/move_test.go |
| @@ -15,6 +15,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostfence" |
| "github.com/carlosframework/platform/internal/hostresolve" |
| + "github.com/carlosframework/platform/internal/provision" |
| "io" |
| "net/http" |
| "net/http/httptest" |
| @@ -1070,3 +1071,21 @@ func TestMoveDeclareLost(t *testing.T) { |
| wantLines(t, out, "declare-lost is not enabled in this deployment yet") |
| }) |
| } |
| + |
| +// 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. |
| +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)}} |
| + code, out := runMoveAgainst(t, c, "status", "--host", tHost) |
| + 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) |
| + } |
| + 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) |
| + } |
| +} |