From 635f70a95961879e838b2c5e54702a4f4a8041e7 Mon Sep 17 00:00:00 2001 From: Jeroen Soeters Date: Sat, 22 Aug 2026 01:04:14 -0700 Subject: [PATCH] test(blackbox): revert optimistic properties for slots a failed command never touched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed or canceled command's unmentioned slot was never touched by the agent, but the unmentioned-slot revert only fired when the slot's State differed from its snapshot. An optimistic property prediction — a patch merge applied to the model at submission — therefore survived when the command failed on a sibling before reaching the slot: the model expected merged properties the agent never wrote, while State stayed Exists on both sides. Revert properties along with state whenever either differs from the snapshot. Reverse-order processing keeps stacked reverts convergent: the oldest unmentioned snapshot is the last agent-confirmed state, and slots corrected or superseded by newer events are skipped as before. --- tests/blackbox/executor.go | 13 ++++++++----- tests/blackbox/state_model_test.go | 31 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/tests/blackbox/executor.go b/tests/blackbox/executor.go index 4656295ad..07f160af0 100644 --- a/tests/blackbox/executor.go +++ b/tests/blackbox/executor.go @@ -1004,10 +1004,13 @@ func correctModelFromCommandOutcome(t *testing.T, cmd *apimodel.Command, model * } // Step 2: Handle snapshotted slots not mentioned in the command response. - // If the command failed/canceled, unmentioned slots whose state changed - // from the snapshot must be reverted (implicit reconcile deletes or - // cascade descendants that never ran). Skip slots already corrected by a - // later command. + // If the command failed/canceled, an unmentioned slot was never touched + // by the agent, so every optimistic prediction for it (state from + // implicit reconcile deletes or cascade descendants that never ran, AND + // properties from a patch/update the agent never reached) must revert to + // its snapshot. Reverse-order processing makes stacked reverts converge + // on the oldest unmentioned snapshot, which is the last agent-confirmed + // state. Skip slots already corrected by a later command. // if cmd.State != "Success" { for key, snap := range snapBySlot { @@ -1021,7 +1024,7 @@ func correctModelFromCommandOutcome(t *testing.T, cmd *apimodel.Command, model * continue } res := model.Resource(key.stackIdx, key.slotIdx) - if res == nil || res.State == snap.State { + if res == nil || (res.State == snap.State && res.Properties == snap.Properties) { continue } t.Logf("correctModelFromCommandOutcome: reverting unmentioned slot stack=%s slot=%d from %v to %v (command state=%s)", diff --git a/tests/blackbox/state_model_test.go b/tests/blackbox/state_model_test.go index 312fd9d54..667715ea9 100644 --- a/tests/blackbox/state_model_test.go +++ b/tests/blackbox/state_model_test.go @@ -720,3 +720,34 @@ func TestCorrectModelFromCommandOutcome_TTLSupersededSlotStaysDestroyed(t *testi require.True(t, model.IsAuthoritativeSlot(2, xslot), "the TTL destroy's authoritative mark must survive the stale correction") } + +// A failed command's unmentioned slot was never touched by the agent, so an +// optimistic property prediction for it must roll back even when the slot's +// State never changed. A patch that fails on a sibling before reaching the +// slot would otherwise leave the model expecting merged properties the +// agent never wrote. +func TestCorrectModelFromCommandOutcome_UnmentionedSlotRevertsProperties(t *testing.T) { + model := NewStateModel(1, 3) + model.ApplyCreated(0, []int{1}, `{"Value":"v1"}`) + + // Snapshot at command submission, then the optimistic patch prediction. + snapshots := []ResourceSnapshot{{StackIndex: 0, SlotIndex: 1, State: StateExists, Properties: `{"Value":"v1"}`}} + model.Resource(0, 1).Properties = `{"Value":"v2"}` // optimistic merge + + cmd := &apimodel.Command{ + CommandID: "cmd-failed-before-slot", + State: "Failed", + ResourceUpdates: []apimodel.ResourceUpdate{{ + StackName: "stack-0", + ResourceLabel: "res-stack-0-c", // slot 2 — the failing sibling + Operation: "create", + State: "Failed", + }}, + } + corrected := map[struct{ stackIdx, slotIdx int }]bool{} + correctModelFromCommandOutcome(t, cmd, model, nil, snapshots, corrected, false, nil) + + require.Equal(t, StateExists, model.Resource(0, 1).State) + require.Equal(t, `{"Value":"v1"}`, model.Resource(0, 1).Properties, + "optimistic properties must revert to the snapshot when the failed command never touched the slot") +}