provision, host: MoveAdmit records the admitted probe attempts (Plan 3 Task 6, H10)
MoveAdmit takes the target report's AttemptsAdmitted, the count its T11 pricing admitted, and records it as CutAttempts (additive, omitempty); a negative count is refused. RollbackProbeUnpriced is the landing's recorded refusal when the count is absent (MV-N187, pending), accepted by MoveMarkRollback and MoveRollBack.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+64
−18
approved_moves_copy_test.go+1 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+1 −0internal/host/movescut.go+1 −1internal/host/movescut_test.go+3 −3internal/provision/move_probeattempts_test.go+30 −0internal/provision/move_test.go+5 −5internal/provision/provision.go+23 −8
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 89f05f20f..f4591b472 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -70,7 +70,7 @@ var movesCopyIDs = []string{ |
| "MV-N163", "MV-N164", "MV-N165", "MV-N166", "MV-N167", "MV-N168", |
| "MV-N169", "MV-N170", "MV-N171", "MV-N172", "MV-N173", "MV-N174", |
| "MV-N175", "MV-N176", "MV-N177", "MV-N178", "MV-N179", "MV-N180", "MV-N181", |
| - "MV-N182", "MV-N183", "MV-N184", "MV-N185", "MV-N186", |
| + "MV-N182", "MV-N183", "MV-N184", "MV-N185", "MV-N186", "MV-N187", |
| "MV-N300", "MV-N301", "MV-N302", "MV-N303", "MV-N304", "MV-N305", "MV-N306", |
| "MV-N307", "MV-N308", "MV-N309", "MV-N310", "MV-N311", "MV-N312", |
| } |
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 b56d80ec7..64d2daf10 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -240,6 +240,7 @@ Columns: id, file, text, status. |
| | MV-N184 | internal/moves/startcheck.go | this box holds another generation of the instance | approved | |
| | MV-N185 | internal/moves/startcheck.go | the instance's files belong to another generation | approved | |
| | MV-N186 | internal/moves/startcheck.go | the instance's database belongs to another generation | approved | |
| +| MV-N187 | internal/provision/provision.go | the cut recorded no probe attempts for this landing | pending | |
| ## Plan 4 |
diff --git a/internal/host/movescut.go b/internal/host/movescut.go| index fbf4d0cb8..8c10e18b1 100644 |
| --- a/internal/host/movescut.go |
| +++ b/internal/host/movescut.go |
| @@ -239,7 +239,7 @@ func (c *cutRun) requested(ctx context.Context) (bool, error) { |
| } |
| now := cfg.Now() |
| budget := target.CutBudgetS |
| - return c.cutTransition(ctx, "the admission", provision.MoveAdmit(c.g, now, budget, moves.CutDeadline(now, float64(budget)))) |
| + return c.cutTransition(ctx, "the admission", provision.MoveAdmit(c.g, now, budget, moves.CutDeadline(now, float64(budget)), target.AttemptsAdmitted)) |
| } |
| // admitted (§7.4): the pinned inputs still match the current bundle and |
diff --git a/internal/host/movescut_test.go b/internal/host/movescut_test.go| index 38617cb8d..a94566d51 100644 |
| --- a/internal/host/movescut_test.go |
| +++ b/internal/host/movescut_test.go |
| @@ -331,7 +331,7 @@ func TestCutGoesBackToReadyWhenInputsMove(t *testing.T) { |
| h2.ready(t) |
| h2.cut(t) |
| now := h2.clock.Now() |
| - if _, err := provision.SetRecordMove(ctx, h2.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120))); err != nil { |
| + if _, err := provision.SetRecordMove(ctx, h2.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120), 3)); err != nil { |
| t.Fatal(err) |
| } |
| h2.bundle["default"] = "e9" |
| @@ -423,7 +423,7 @@ func TestCutRefusesAScopedHolder(t *testing.T) { |
| h2.ready(t) |
| h2.cut(t) |
| now := h2.clock.Now() |
| - if _, err := provision.SetRecordMove(ctx, h2.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120))); err != nil { |
| + if _, err := provision.SetRecordMove(ctx, h2.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120), 3)); err != nil { |
| t.Fatal(err) |
| } |
| h2.pass(t) |
| @@ -985,7 +985,7 @@ func TestCutDeadlinePassedReverts(t *testing.T) { |
| h.ready(t) |
| h.cut(t) |
| now := h.clock.Now() |
| - if _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, provision.MoveAdmit(g, now, 120, moves.CutDeadline(now, 120))); err != nil { |
| + 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) |
| } |
| h.clock.Advance(2 * time.Minute) |
diff --git a/internal/provision/move_probeattempts_test.go b/internal/provision/move_probeattempts_test.go| new file mode 100644 |
| index 000000000..ddc49a441 |
| --- /dev/null |
| +++ b/internal/provision/move_probeattempts_test.go |
| @@ -0,0 +1,30 @@ |
| +package provision |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// H10 (moves Plan 3 Task 6): the admission records the probe attempts its |
| +// T11 pricing admitted, and an absent count is the landing's recorded |
| +// refusal of the probe. |
| +func TestMoveAdmitRecordsTheProbeAttempts(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Date(2026, 10, 9, 9, 0, 0, 0, time.UTC) |
| + cutting := func() *Record { |
| + return &Record{Region: "ie", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveCutting, CutPhase: CutRequested, Inputs: &MoveInputs{Release: "v1"}}} |
| + } |
| + rec := cutting() |
| + if err := MoveAdmit(g, now, 120, now.Add(time.Minute), 2)(rec); err != nil || rec.Move.CutAttempts != 2 { |
| + t.Fatalf("admit: %v %+v", err, rec.Move) |
| + } |
| + rec = cutting() |
| + if err := MoveAdmit(g, now, 120, now.Add(time.Minute), -1)(rec); !errors.Is(err, ErrMoveConflict) || rec.Move.CutPhase != CutRequested || rec.Move.CutAttempts != 0 { |
| + t.Fatalf("a negative count: %v %+v", err, rec.Move) |
| + } |
| + landing := &Record{Region: "uk", Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: MoveLanding, LandingPhase: LandProbing}} |
| + if err := MoveMarkRollback(g, RollbackProbeUnpriced)(landing); err != nil || landing.Move.Reason != RollbackProbeUnpriced { |
| + t.Fatalf("the unpriced probe's mark: %v %+v", err, landing.Move) |
| + } |
| +} |
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go| index 4a692bd50..46c8d3714 100644 |
| --- a/internal/provision/move_test.go |
| +++ b/internal/provision/move_test.go |
| @@ -275,7 +275,7 @@ func TestCutPhasesAdvanceInOrderUnderTheGuard(t *testing.T) { |
| t.Fatalf("auto cut: %v %+v", err, rec.Move) |
| } |
| deadline := now.Add(115 * time.Second) |
| - if err := MoveAdmit(g, now, 120, deadline)(rec); err != nil || rec.Move.CutPhase != CutAdmitted || !rec.Move.CutDeadline.Equal(deadline) || !rec.Move.CutAt.Equal(now) || rec.Move.CutBudgetS != 120 || rec.Move.Inputs.Release != "v1" { |
| + if err := MoveAdmit(g, now, 120, deadline, 3)(rec); err != nil || rec.Move.CutPhase != CutAdmitted || !rec.Move.CutDeadline.Equal(deadline) || !rec.Move.CutAt.Equal(now) || rec.Move.CutBudgetS != 120 || rec.Move.Inputs.Release != "v1" { |
| t.Fatalf("admit: %v %+v", err, rec.Move) |
| } |
| // Wrong incarnation: refused. |
| @@ -487,12 +487,12 @@ func TestInputsLoopPinDriftBackRepinCutAdmit(t *testing.T) { |
| t.Fatalf("repin in cutting: %v", err) |
| } |
| // Admit pins nothing: it stamps the clock fields and keeps the pin. |
| - if err := MoveAdmit(g, now, 120, now.Add(time.Minute))(rec); err != nil || rec.Move.CutPhase != CutAdmitted || rec.Move.Inputs.Release != "v2" { |
| + if err := MoveAdmit(g, now, 120, now.Add(time.Minute), 3)(rec); err != nil || rec.Move.CutPhase != CutAdmitted || rec.Move.Inputs.Release != "v2" { |
| t.Fatalf("admit: %v %+v", err, rec.Move) |
| } |
| // Admit on a cut with no pin is refused and writes nothing. |
| bare := &Record{Move: &MoveState{ID: g.ID, Incarnation: g.Incarnation, State: MoveCutting, CutPhase: CutRequested}} |
| - if err := MoveAdmit(g, now, 120, now.Add(time.Minute))(bare); !errors.Is(err, ErrMoveConflict) || bare.Move.CutPhase != CutRequested || bare.Move.CutAt != nil || bare.Move.CutBudgetS != 0 { |
| + if err := MoveAdmit(g, now, 120, now.Add(time.Minute), 3)(bare); !errors.Is(err, ErrMoveConflict) || bare.Move.CutPhase != CutRequested || bare.Move.CutAt != nil || bare.Move.CutBudgetS != 0 { |
| t.Fatalf("admit with no pin: %v %+v", err, bare.Move) |
| } |
| } |
| @@ -539,7 +539,7 @@ func TestFlipIsOneRecordWrite(t *testing.T) { |
| MoveStartTransition(MoveStart{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", Cut: "auto", By: "ops@example.com", Now: now}), |
| MoveGuardedPromoteReady(g, now, MoveInputs{Release: "v1"}), |
| MoveGuardedCut(g), |
| - MoveAdmit(g, now, 120, now.Add(115*time.Second)), |
| + MoveAdmit(g, now, 120, now.Add(115*time.Second), 3), |
| MoveCutPhase(g, CutAdmitted, CutStopping, nil), |
| MoveCutPhase(g, CutStopping, CutStopped, nil), |
| MoveCutPhase(g, CutStopped, CutStaged, nil), |
| @@ -579,7 +579,7 @@ func TestBackToReadyRecordsARefusal(t *testing.T) { |
| } |
| rec.Move.Inputs = &MoveInputs{Release: "v1"} |
| rec.Move.State, rec.Move.CutPhase = MoveCutting, CutRequested |
| - if err := MoveAdmit(g, now, 120, now)(rec); err != nil || rec.Move.Reason != "" { |
| + if err := MoveAdmit(g, now, 120, now, 3)(rec); err != nil || rec.Move.Reason != "" { |
| t.Fatalf("the admission did not clear the reason: %v %+v", err, rec.Move) |
| } |
| } |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index cda7ff114..57ca898d1 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -957,9 +957,13 @@ type MoveState struct { |
| // admission (§7.3 T3, §7.4 admitted, INV-6). |
| Inputs *MoveInputs `json:"inputs,omitempty"` |
| - CutPhase string `json:"cut_phase,omitempty"` |
| - CutAt *time.Time `json:"cut_at,omitempty"` |
| - CutBudgetS int `json:"cut_budget_s,omitempty"` |
| + CutPhase string `json:"cut_phase,omitempty"` |
| + CutAt *time.Time `json:"cut_at,omitempty"` |
| + CutBudgetS int `json:"cut_budget_s,omitempty"` |
| + // CutAttempts is the number of probe attempts the admission's T11 |
| + // pricing admitted (the target report's AttemptsAdmitted, H10). The |
| + // landing's probe reads it; absent refuses the probe. |
| + CutAttempts int `json:"cut_attempts,omitempty"` |
| CutDeadline *time.Time `json:"cut_deadline,omitempty"` |
| Source *MoveSource `json:"source,omitempty"` |
| @@ -1417,12 +1421,12 @@ func MoveCutPhase(g MoveGuard, from, to string, stamp func(*MoveState)) func(*Re |
| } |
| } |
| -// MoveAdmit is §7.4 requested to admitted: the budget and the absolute |
| -// deadline, in one write. It pins nothing: the inputs were pinned at |
| +// MoveAdmit is §7.4 requested to admitted: the budget, the absolute |
| +// deadline and the probe attempts T11 admitted (H10), in one write. It pins nothing: the inputs were pinned at |
| // readiness (MoveGuardedPromoteReady, or MoveGuardedRepin after a back to |
| // ready), and the source compares the current inputs with that pin before |
| // this CAS. A cut with no pin is refused. |
| -func MoveAdmit(g MoveGuard, now time.Time, budgetS int, deadline time.Time) func(*Record) error { |
| +func MoveAdmit(g MoveGuard, now time.Time, budgetS int, deadline time.Time, attempts int) func(*Record) error { |
| return func(rec *Record) error { |
| if err := g.check(rec); err != nil { |
| return err |
| @@ -1430,9 +1434,12 @@ func MoveAdmit(g MoveGuard, now time.Time, budgetS int, deadline time.Time) func |
| if rec.Move.Inputs == nil { |
| return fmt.Errorf("%w: the move has no pinned inputs to admit against", ErrMoveConflict) |
| } |
| + if attempts < 0 { |
| + return fmt.Errorf("%w: a negative count of probe attempts", ErrMoveConflict) |
| + } |
| return MoveCutPhase(g, CutRequested, CutAdmitted, func(m *MoveState) { |
| t, d := now.UTC(), deadline.UTC() |
| - m.CutAt, m.CutBudgetS, m.CutDeadline = &t, budgetS, &d |
| + m.CutAt, m.CutBudgetS, m.CutDeadline, m.CutAttempts = &t, budgetS, &d, attempts |
| // A refusal an earlier cut recorded (MoveBackToReadyRefused) is |
| // over once a cut admits. |
| m.Reason = "" |
| @@ -1627,6 +1634,10 @@ const ( |
| // RollbackCommitDeadline: the probe passed, but the commit would have |
| // come after the cut or landing deadline (§7.9). |
| RollbackCommitDeadline = "the commit came after the deadline" |
| + // RollbackProbeUnpriced: the admission recorded no probe attempts for |
| + // the landing (H10: MoveAdmit's CutAttempts is absent), so the probe |
| + // cannot be bounded. |
| + RollbackProbeUnpriced = "the cut recorded no probe attempts for this landing" |
| ) |
| // MoveMarkRollback records on a landing record why the landing stopped |
| @@ -1665,6 +1676,8 @@ func MoveMarkRollback(g MoveGuard, reason string) func(*Record) error { |
| rec.Move.Reason = RollbackProbeAttempts |
| case RollbackCommitDeadline: |
| rec.Move.Reason = RollbackCommitDeadline |
| + case RollbackProbeUnpriced: |
| + rec.Move.Reason = RollbackProbeUnpriced |
| default: |
| return fmt.Errorf("provision: rollback reason %q has no recorded sentence", reason) |
| } |
| @@ -1922,6 +1935,8 @@ func MoveRollBack(g MoveGuard, rbID, reason string, now time.Time) func(*Record) |
| rb.Reason = RollbackProbeAttempts |
| case RollbackCommitDeadline: |
| rb.Reason = RollbackCommitDeadline |
| + case RollbackProbeUnpriced: |
| + rb.Reason = RollbackProbeUnpriced |
| case RollbackDeclaredLost: |
| rb.Reason = RollbackDeclaredLost |
| default: |
| @@ -1941,7 +1956,7 @@ func MoveRollBack(g MoveGuard, rbID, reason string, now time.Time) func(*Record) |
| func recordedRollbackReason(r string) bool { |
| switch r { |
| case RollbackConfigChanged, RollbackLandingDeadline, RollbackClaimWired, RollbackLandingLayout, RollbackInstallMismatch, |
| - RollbackFilesOccupied, RollbackProbeAttempts, RollbackCommitDeadline, RollbackDeclaredLost: |
| + RollbackFilesOccupied, RollbackProbeAttempts, RollbackCommitDeadline, RollbackDeclaredLost, RollbackProbeUnpriced: |
| return true |
| } |
| return false |