carlos / platform Public

moves cut guard: a refusal ends the step even when its write conflicts

Fix round 1, item 1. refuseUnarmed returned the transition's result, and
cutTransition answers ErrMoveConflict with (false, nil), so a refusal
that lost its record write to another writer let requested carry on to
the source's round (which takes the staging lease) and admitted to the
step to stopping. It now returns the guard's verdict and the write's
outcome apart; both callers end the step on a refusal and report only
whether the record was written, so the next pass re-reads.

A "refusal" fault point before the write lets the test inject the
conflicting writer. TestCutGuardRefusalEndsTheStepOnAConflict: at
requested and at admitted, no staging lease, hold, stop evidence,
checkpoint or seal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 84c8d8736ca97e1ea51783e97b6ff856445163d3 parent 56d1773
2 files changed, +62 −10
  • internal/host/movescut.go +20 −10
  • internal/host/movescutguard_test.go +42 −0
diff --git a/internal/host/movescut.go b/internal/host/movescut.go
index 443cb026c..a0bbcb165 100644
--- a/internal/host/movescut.go
+++ b/internal/host/movescut.go
@@ -217,8 +217,8 @@ func (c *cutRun) revert(ctx context.Context, reason string) (bool, error) {
// round rewrites.
func (c *cutRun) requested(ctx context.Context) (bool, error) {
cfg := c.cfg()
- if refused, err := c.refuseUnarmed(ctx); refused || err != nil {
- return refused, err
+ if refused, wrote, err := c.refuseUnarmed(ctx); refused || err != nil {
+ return wrote, err
}
sr, published, err := c.w.sourceRound(ctx, c.s, c.rec, c.fleet)
if err != nil || published == nil {
@@ -264,8 +264,8 @@ func (c *cutRun) admitted(ctx context.Context) (bool, error) {
if refused, err := c.refuseHolder(ctx); refused || err != nil {
return refused, err
}
- if refused, err := c.refuseUnarmed(ctx); refused || err != nil {
- return refused, err
+ if refused, wrote, err := c.refuseUnarmed(ctx); refused || err != nil {
+ return wrote, err
}
return c.advance(ctx, provision.CutAdmitted, provision.CutStopping, nil)
}
@@ -282,28 +282,38 @@ func (c *cutRun) admitted(ctx context.Context) (bool, error) {
// move again while this box cannot recover, and the target's failing T13
// keeps Ready from pinning it (§7.3), so nothing loops and the operator
// reads why in `carlos move status`.
-func (c *cutRun) refuseUnarmed(ctx context.Context) (bool, error) {
+//
+// refused and wrote are apart (fix round 1): refused is the guard's
+// verdict, wrote whether the refusal's record write landed. A refused cut
+// ends the step either way. When another writer moved the record first
+// (ErrMoveConflict, which cutTransition logs and answers false) nothing
+// was written, the pass ends, and the next pass reads the record again;
+// the cut never carries on to the source's round or to stopping.
+func (c *cutRun) refuseUnarmed(ctx context.Context) (refused, wrote bool, err error) {
reason := ""
if !recoverySourceArmed(c.cfg()) {
reason = provision.RefusedSourceUnarmed
} else {
target, err := readTargetReport(ctx, c.s, c.rec)
if err != nil {
- return false, err
+ return false, false, err
}
if !target.Armed() {
reason = provision.RefusedTargetUnarmed
}
}
if reason == "" {
- return false, nil
+ return false, false, nil
}
log.Printf("moves: %s: cut %s refused: %s", c.rec.Host, c.g.ID, reason)
- ok, err := c.cutTransition(ctx, "the refusal", provision.MoveBackToReadyRefused(c.g, reason))
- if ok {
+ if err := c.fault("refusal"); err != nil {
+ return true, false, err
+ }
+ wrote, err = c.cutTransition(ctx, "the refusal", provision.MoveBackToReadyRefused(c.g, reason))
+ if wrote {
nudgeSerial(ctx, c.s)
}
- return ok, err
+ return true, wrote, err
}
// refuseHolder reads the claim fresh and, when its holder is not at the
diff --git a/internal/host/movescutguard_test.go b/internal/host/movescutguard_test.go
index 84b3cb034..d72d50a10 100644
--- a/internal/host/movescutguard_test.go
+++ b/internal/host/movescutguard_test.go
@@ -357,3 +357,45 @@ func TestTargetReportStatesItsArming(t *testing.T) {
t.Fatalf("an armed target's move: %+v", m)
}
}
+
+// Fix round 1: a refusal whose record write loses to another writer still
+// ends the step. Here another writer takes the move back to ready just
+// before the refusal's write, so the refusal's CAS conflicts; the pass must
+// not carry on with the cut (the source's round and its staging lease at
+// requested, the step to stopping at admitted). The next pass re-reads.
+func TestCutGuardRefusalEndsTheStepOnAConflict(t *testing.T) {
+ ctx := context.Background()
+ for _, phase := range []string{provision.CutRequested, provision.CutAdmitted} {
+ t.Run(phase, func(t *testing.T) {
+ h := newCutHarness(t)
+ h.ready(t)
+ h.targetPass(t, h.unarmedTarget(t, func(c *moveCfg) { c.OwnLease = nil }))
+ h.cut(t)
+ g := provision.MoveGuard{ID: p1ID, Incarnation: h.inc}
+ if phase == provision.CutAdmitted {
+ now := h.clock.Now()
+ if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120), 3)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ inner, conflicted := h.cfg.cutFault, false
+ h.cfg.cutFault = func(point string) error {
+ if point == "refusal" && !conflicted {
+ conflicted = true
+ if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveBackToReady(g)); err != nil {
+ t.Errorf("the conflicting write: %v", err)
+ }
+ }
+ return inner(point)
+ }
+ acquired, statuses := h.lease.acquired, len(h.statuses)
+ h.pass(t)
+ if !conflicted {
+ t.Fatal("the pass never reached the refusal")
+ }
+ // The other writer's decision stands: ready, unpinned, and
+ // no reason, since its write carried none.
+ h.nothingStopped(t, "", acquired, statuses)
+ })
+ }
+}