carlos / platform Public

moves log: a stuck target release names the target (final review M1)

TransitionOf gave role=target only for a stuck rollback whose target was
returning. The target's release can also stop for an operator, at
releasing with StuckReleaseTargetFiles, and its immutable stuck entry then
named the source. It now names the target. The regression covers the
release, the return and a source stuck.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9ca5601455e0e008280fc4ce835e6bd249c3840e parent eb84093
2 files changed, +41 −3
  • internal/moves/transition.go +5 −3
  • internal/moves/transition_test.go +36 −0
diff --git a/internal/moves/transition.go b/internal/moves/transition.go
index a71fc31fb..548201780 100644
--- a/internal/moves/transition.go
+++ b/internal/moves/transition.go
@@ -147,10 +147,12 @@ func TransitionOf(before, after provision.Record) (Transition, bool) {
return Transition{}, false
}
e.Event, e.Identity = "stuck", "stuck-"+strconv.FormatUint(am.RecoveryRetryEpoch, 10)
- // A rollback's target stops for an operator only at its return
- // (returning); every other stuck is the source's.
+ // A rollback's target stops for an operator at its return
+ // (returning) and at its release (releasing, whose files cannot be
+ // archived: final review M1); every other stuck is the source's.
e.Fields["role"] = "source"
- if am.Rollback != nil && am.Rollback.TargetPhase == provision.RbTargetReturning {
+ if rb := am.Rollback; rb != nil && (rb.TargetPhase == provision.RbTargetReturning ||
+ rb.TargetPhase == provision.RbTargetReleasing && am.StuckReason == provision.StuckReleaseTargetFiles) {
e.Fields["role"] = "target"
}
if am.StuckReason != "" {
diff --git a/internal/moves/transition_test.go b/internal/moves/transition_test.go
new file mode 100644
index 000000000..64265e7e4
--- /dev/null
+++ b/internal/moves/transition_test.go
@@ -0,0 +1,36 @@
+package moves
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// Final review M1: a stuck entry names the participant that stopped. The
+// target stops for an operator at its return (returning) and at its release
+// (releasing, StuckReleaseTargetFiles); every other stuck is the source's.
+func TestStuckNamesTheParticipantThatStopped(t *testing.T) {
+ rec := func(state, target, source, reason string, dirty bool) provision.Record {
+ return provision.Record{Host: "a.example.com", Move: &provision.MoveState{ID: "mv_abcdefghijklmnopqrstuvwxyz", Incarnation: "i", From: "ie", To: "uk",
+ State: state, StuckReason: reason,
+ Rollback: &provision.MoveRollback{ID: "rb_abcdefghijklmnopqrstuvwxyz", TargetPhase: target, SourcePhase: source, Dirty: dirty}}}
+ }
+ for _, c := range []struct {
+ name string
+ target, source string
+ reason string
+ dirty bool
+ want string
+ }{
+ {"the target's release", provision.RbTargetReleasing, provision.RbSourceWaiting, provision.StuckReleaseTargetFiles, false, "target"},
+ {"the target's return", provision.RbTargetReturning, provision.RbSourceWaiting, provision.StuckReturnTargetFiles, true, "target"},
+ {"the source's verification", provision.RbTargetCleaned, provision.RbSourceVerified, provision.StuckBaselineMismatch, false, "source"},
+ } {
+ before := rec(provision.MoveRollingBack, c.target, c.source, "", c.dirty)
+ after := rec(provision.MoveStuck, c.target, c.source, c.reason, c.dirty)
+ e, ok := TransitionOf(before, after)
+ if !ok || e.Event != "stuck" || e.Fields["role"] != c.want || e.Fields["reason"] != c.reason {
+ t.Fatalf("%s: %+v %v, want stuck with role %s", c.name, e, ok, c.want)
+ }
+ }
+}