carlos / platform Public

moves: the probe attempt count, and the probe window boundaries pinned (§7.7, R12-2)

AttemptsFor gives a landing the attempts T11 admitted, at most three, and
a recovery nine. The 30 s window, the cut deadline, five starts and the
backoff are main's probeWindow and start loop already (ruling H17); the
model's TestProbeWindow is ported as a test of probeWindow at main's
boundary (readiness at the window or the deadline fails).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 96144a1cbdbc1d35b330a3e6950da23dbb62167e parent 4f26f15
4 files changed, +157 −0
  • approved_moves_copy_test.go +1 −0
  • internal/activator/claimwindow_test.go +88 −0
  • internal/moves/probe.go +38 −0
  • internal/moves/probe_test.go +30 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 04db0fd77..197d9427c 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -620,6 +620,7 @@ var movesScanFiles = []string{
"internal/moves/journal.go",
"internal/moves/lease.go",
"internal/moves/ltxbuild.go",
+ "internal/moves/probe.go",
"internal/moves/readiness.go",
"internal/moves/seal.go",
"internal/moves/sealedclient.go",
diff --git a/internal/activator/claimwindow_test.go b/internal/activator/claimwindow_test.go
index 62fdf0471..760ae6613 100644
--- a/internal/activator/claimwindow_test.go
+++ b/internal/activator/claimwindow_test.go
@@ -88,3 +88,91 @@ func TestClaimProbeWindowJournalsFirstStartAndSurvivesRetry(t *testing.T) {
t.Fatalf("expired retry: %v", err)
}
}
+
+// Ported from the model's TestProbeWindow and wProbeWindowAt (R12-2,
+// INV-19, internal/sim/hostlife/witness_test.go): readiness k seconds after
+// the attempt's first start. Main's boundary is adopted (ruling H17): the
+// window and the cut deadline both fail at the instant, not after it
+// (claimwindow.go: !now.Before), so 29 is accepted, 30 and 31 are not.
+//
+// The loop around probeWindow is not reachable here. Its Ready hook runs
+// only after RoleLaunch.Start, which needs the isolated root, so the
+// readiness decision is tested where the loop reads it: probeWindow.start
+// before a start and probeWindow.result after readiness.
+func TestProbeWindowBoundaries(t *testing.T) {
+ t0 := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ far := t0.Add(time.Hour)
+ newWindow := func(deadline time.Time, now *time.Time) (*probeWindow, func(context.Context, time.Time) (time.Time, error), *int) {
+ var saved time.Time
+ calls := 0
+ journal := func(_ context.Context, proposed time.Time) (time.Time, error) {
+ calls++
+ if saved.IsZero() {
+ saved = proposed
+ }
+ return saved, nil
+ }
+ return &probeWindow{policy: startPolicy{MaxStarts: 5, Probe: true, Window: 30 * time.Second, Deadline: deadline}, now: func() time.Time { return *now }}, journal, &calls
+ }
+ for k, want := range map[int]bool{29: true, 30: false, 31: false} {
+ now := t0
+ b, journal, _ := newWindow(far, &now)
+ if err := b.start(t.Context(), journal); err != nil {
+ t.Fatalf("k=%d first start: %v", k, err)
+ }
+ now = t0.Add(time.Duration(k) * time.Second)
+ err := b.result(nil)
+ if (err == nil) != want || (err != nil && !errors.Is(err, ErrAttemptOver)) {
+ t.Fatalf("k=%d: readiness result %v, accepted want %v", k, err, want)
+ }
+ }
+ // The environment becomes ready only after the window: no further start
+ // is journaled, and the first start's time is not reset.
+ now := t0
+ b, journal, calls := newWindow(far, &now)
+ if err := b.start(t.Context(), journal); err != nil || *calls != 1 {
+ t.Fatalf("first start: %v (journal calls %d)", err, *calls)
+ }
+ now = t0.Add(30 * time.Second)
+ if err := b.start(t.Context(), journal); !errors.Is(err, ErrAttemptOver) || *calls != 1 {
+ t.Fatalf("start after the window: %v (journal calls %d)", err, *calls)
+ }
+ // The absolute cut deadline bounds the whole, inside the window.
+ now = t0
+ c, journal, _ := newWindow(t0.Add(4*time.Second), &now)
+ if err := c.start(t.Context(), journal); err != nil {
+ t.Fatal(err)
+ }
+ now = t0.Add(3 * time.Second)
+ if err := c.result(nil); err != nil {
+ t.Fatalf("readiness before the cut deadline: %v", err)
+ }
+ now = t0.Add(4 * time.Second)
+ if err := c.result(nil); !errors.Is(err, ErrAttemptOver) {
+ t.Fatalf("readiness at the cut deadline: %v", err)
+ }
+ now = t0.Add(5 * time.Second)
+ if err := c.start(t.Context(), journal); !errors.Is(err, ErrAttemptOver) {
+ t.Fatalf("start past the cut deadline: %v", err)
+ }
+ // A readiness already accepted is not revisited by a later clock.
+ now = t0
+ d, journal, _ := newWindow(far, &now)
+ if err := d.start(t.Context(), journal); err != nil {
+ t.Fatal(err)
+ }
+ d.ready = true
+ now = t0.Add(time.Hour)
+ if err := d.result(nil); err != nil {
+ t.Fatalf("accepted readiness revisited: %v", err)
+ }
+ // Five starts at most, whatever the clock says.
+ a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box", Read: func(context.Context, string) (hostclaim.Observed, error) { return hostclaim.Observed{}, nil }, Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) {
+ t.Fatal("six starts were allowed to prepare")
+ return ClaimStart{}, nil
+ }}}}
+ obs := hostclaim.Observed{Host: "x.example"}
+ if err := a.startAdmitted(t.Context(), &actEntry{host: obs.Host}, obs, startPolicy{MaxStarts: 6, Window: 30 * time.Second, Probe: true}); !errors.Is(err, hostwrite.ErrUnavailable) {
+ t.Fatalf("a sixth start: %v", err)
+ }
+}
diff --git a/internal/moves/probe.go b/internal/moves/probe.go
new file mode 100644
index 000000000..a4e9bd3d9
--- /dev/null
+++ b/internal/moves/probe.go
@@ -0,0 +1,38 @@
+package moves
+
+// The probe's attempt policy (moves spec §7.7, R12-2, INV-19).
+//
+// The window itself is not restated here. The activator's probeWindow and
+// start loop (internal/activator/claimwindow.go, claimwake.go) already
+// enforce the 30 second window counted from the attempt's first start, the
+// absolute cut deadline before every start and before readiness is
+// accepted, at most five starts and the doubling backoff between them. This
+// file holds the one piece of policy that lives with the moves: how many
+// attempts a probe is given. The per-attempt cost that prices them is
+// budget.go's, and ProbeAttemptsMax is its ceiling.
+
+// RecoveryAttempts is the number of probe attempts a recovery install is
+// given (§7.7): three rounds of the landing's three, because a recovery has
+// nowhere further to fall back to.
+const RecoveryAttempts = 9
+
+// Probe kinds, as the operation kinds the claim records.
+const (
+ ProbeLanding = "landing"
+ ProbeRecovery = "recovery"
+)
+
+// AttemptsFor is how many probe attempts a probe of the given kind may
+// make. A landing gets the attempts T11 admitted (budget.go
+// AttemptsAdmitted), at most ProbeAttemptsMax, and none when none were
+// admitted. A recovery gets RecoveryAttempts whatever was admitted. A kind
+// that is neither gets none.
+func AttemptsFor(kind string, admitted int) int {
+ switch kind {
+ case ProbeLanding:
+ return max(0, min(ProbeAttemptsMax, admitted))
+ case ProbeRecovery:
+ return RecoveryAttempts
+ }
+ return 0
+}
diff --git a/internal/moves/probe_test.go b/internal/moves/probe_test.go
new file mode 100644
index 000000000..83c15b68e
--- /dev/null
+++ b/internal/moves/probe_test.go
@@ -0,0 +1,30 @@
+package moves
+
+import "testing"
+
+func TestAttemptsFor(t *testing.T) {
+ for _, c := range []struct {
+ kind string
+ admitted int
+ want int
+ }{
+ {"landing", 2, 2},
+ {"landing", 7, 3},
+ {"landing", 3, 3},
+ {"landing", 1, 1},
+ {"landing", 0, 0},
+ {"landing", -4, 0},
+ {"recovery", 0, 9},
+ {"recovery", 2, 9},
+ {"recovery", 7, 9},
+ {"serving", 3, 0},
+ {"", 3, 0},
+ } {
+ if got := AttemptsFor(c.kind, c.admitted); got != c.want {
+ t.Errorf("AttemptsFor(%q, %d) = %d, want %d", c.kind, c.admitted, got, c.want)
+ }
+ }
+ if ProbeAttemptsMax != 3 || RecoveryAttempts != 9 {
+ t.Fatal("the attempt ceilings are the spec's: 3 for a landing, 9 for a recovery")
+ }
+}