This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new 2054dfd09 [runtime][java] Reject memory writes below a value field 
before mutating state (#1188)
2054dfd09 is described below

commit 2054dfd0904cef91fcd9c2e1689419cbcf0e29fb
Author: Sangkyoon Nam <[email protected]>
AuthorDate: Mon Oct 12 16:32:46 2026 +0900

    [runtime][java] Reject memory writes below a value field before mutating 
state (#1188)
    
    Generated-by: Claude Code 2.1.284 (Claude Fable 5.1)
---
 .../flink/agents/api/context/MemoryObject.java     |  11 +-
 .../agents/runtime/memory/MemoryObjectImpl.java    |  39 +++-
 .../agents/runtime/memory/MemoryObjectTest.java    |  97 +++++++++
 .../runtime/memory/MemoryUpdateReplayerTest.java   |  25 +++
 .../MemoryWriteBelowValueRecoveryTest.java         | 235 +++++++++++++++++++++
 5 files changed, 400 insertions(+), 7 deletions(-)

diff --git 
a/api/src/main/java/org/apache/flink/agents/api/context/MemoryObject.java 
b/api/src/main/java/org/apache/flink/agents/api/context/MemoryObject.java
index 2b19062ea..4fd34fad8 100644
--- a/api/src/main/java/org/apache/flink/agents/api/context/MemoryObject.java
+++ b/api/src/main/java/org/apache/flink/agents/api/context/MemoryObject.java
@@ -60,8 +60,9 @@ public interface MemoryObject {
      * @param path relative path from the current object to the target field
      * @param value new value of the field
      * @return a {@link MemoryRef} instance pointing to the data just set
-     * @throws Exception if trying to overwrite a nested object with a 
primitive value or set a
-     *     MemoryObject directly
+     * @throws Exception if trying to overwrite a nested object with a 
primitive value, set a
+     *     MemoryObject directly, write below a field that holds a value, or 
use a path with an
+     *     empty component
      */
     MemoryRef set(String path, Object value) throws Exception;
 
@@ -71,7 +72,8 @@ public interface MemoryObject {
      * @param path relative path from the current object to the target field
      * @param overwrite whether to overwrite existing field if it's not a 
nested object
      * @return the created object
-     * @throws Exception if field exists but is not a nested object and 
overwrite is false
+     * @throws Exception if field exists but is not a nested object and 
overwrite is false, if the
+     *     path runs below a field that holds a value, or if the path has an 
empty component
      */
     MemoryObject newObject(String path, boolean overwrite) throws Exception;
 
@@ -81,7 +83,8 @@ public interface MemoryObject {
      *
      * @param path relative path from the current object to the target field
      * @return the created object
-     * @throws Exception if field exists but is not a nested object and 
overwrite is false
+     * @throws Exception if field exists but is not a nested object, if the 
path runs below a field
+     *     that holds a value, or if the path has an empty component
      */
     default MemoryObject newObject(String path) throws Exception {
         return this.newObject(path, false);
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/memory/MemoryObjectImpl.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/memory/MemoryObjectImpl.java
index fb86d5040..501fc5b66 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/memory/MemoryObjectImpl.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/memory/MemoryObjectImpl.java
@@ -95,7 +95,7 @@ public class MemoryObjectImpl implements MemoryObject {
     public MemoryRef set(String path, Object value) throws Exception {
         mailboxThreadChecker.run();
         String absPath = fullPath(path);
-        String[] parts = absPath.split("\\.");
+        String[] parts = checkWritePath(absPath);
         fillParents(parts);
 
         String parent =
@@ -122,8 +122,7 @@ public class MemoryObjectImpl implements MemoryObject {
     public MemoryObject newObject(String path, boolean overwrite) throws 
Exception {
         mailboxThreadChecker.run();
         String absPath = fullPath(path);
-        String[] parts = absPath.split("\\.");
-
+        String[] parts = checkWritePath(absPath);
         fillParents(parts);
 
         if (store.contains(absPath)) {
@@ -217,6 +216,40 @@ public class MemoryObjectImpl implements MemoryObject {
         return (prefix.isEmpty() ? path : prefix + SEPARATOR + path);
     }
 
+    /**
+     * Splits a write path into its components and rejects a path that has an 
empty component
+     * (leading, trailing or doubled separator) or runs through a field that 
holds a value. Must run
+     * before any mutation, so that a rejected write leaves neither 
intermediate nodes nor a
+     * recorded update behind.
+     */
+    private String[] checkWritePath(String absPath) throws Exception {
+        // Keep trailing empty components, so that every component is checked.
+        String[] parts = absPath.split("\\.", -1);
+        for (String part : parts) {
+            if (part.isEmpty()) {
+                throw new IllegalArgumentException(
+                        "Cannot write field '" + absPath + "': path has an 
empty component.");
+            }
+        }
+
+        StringBuilder path = new StringBuilder();
+        for (int i = 0; i < parts.length - 1; i++) {
+            if (i > 0) path.append(SEPARATOR);
+            path.append(parts[i]);
+
+            MemoryItem ancestor = store.get(path.toString());
+            if (ancestor != null && ancestor.getType() != ItemType.OBJECT) {
+                throw new IllegalArgumentException(
+                        "Cannot write field '"
+                                + absPath
+                                + "': '"
+                                + path
+                                + "' exists but is not an object.");
+            }
+        }
+        return parts;
+    }
+
     private void fillParents(String[] parts) throws Exception {
         StringBuilder path = new StringBuilder();
         for (int i = 0; i < parts.length - 1; i++) {
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/memory/MemoryObjectTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/memory/MemoryObjectTest.java
index 10369cdb9..c8de18c00 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/memory/MemoryObjectTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/memory/MemoryObjectTest.java
@@ -25,6 +25,7 @@ import org.junit.jupiter.api.Test;
 import java.util.*;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.junit.jupiter.api.Assertions.*;
 
 /** Tests for {@link MemoryObject}. */
@@ -158,6 +159,102 @@ public class MemoryObjectTest {
         assertEquals("v", memory.get("scalar.k").getValue());
     }
 
+    @Test
+    void testSetUnderValueAncestorIsRejectedWithoutSideEffects() throws 
Exception {
+        memory.set("a", 1);
+
+        assertThatThrownBy(() -> memory.set("a.b", 2))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("Cannot write field 'a.b': 'a' exists but is not 
an object.");
+        assertThatThrownBy(() -> memory.set("a.b.c", 2))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("Cannot write field 'a.b.c': 'a' exists but is not 
an object.");
+        MemoryObject valueHandle = memory.get("a");
+        assertThatThrownBy(() -> valueHandle.set("b", 2))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("Cannot write field 'a.b': 'a' exists but is not 
an object.");
+
+        // The rejected writes must not leave intermediate nodes or recorded 
updates behind.
+        assertFalse(memory.isExist("a.b"));
+        assertFalse(memory.isExist("a.b.c"));
+        assertEquals(1, memory.get("a").getValue());
+        assertThat(memory.getFields()).containsOnly(Map.entry("a", 1));
+        assertThat(memoryUpdates).containsExactly(new MemoryUpdate("a", 1));
+    }
+
+    @Test
+    void testNewObjectUnderValueAncestorIsRejectedWithoutSideEffects() throws 
Exception {
+        memory.set("a", 1);
+
+        assertThatThrownBy(() -> memory.newObject("a.b"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("Cannot write field 'a.b': 'a' exists but is not 
an object.");
+        // overwrite applies to the target field, not to its ancestors.
+        assertThatThrownBy(() -> memory.newObject("a.b", true))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("Cannot write field 'a.b': 'a' exists but is not 
an object.");
+        assertThatThrownBy(() -> memory.newObject("a.b.c"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("Cannot write field 'a.b.c': 'a' exists but is not 
an object.");
+
+        assertFalse(memory.isExist("a.b"));
+        assertEquals(1, memory.get("a").getValue());
+        assertThat(memoryUpdates).containsExactly(new MemoryUpdate("a", 1));
+    }
+
+    @Test
+    void testSetWithEmptyPathComponentIsRejectedWithoutSideEffects() throws 
Exception {
+        memory.set("a", 1);
+        memory.newObject("o");
+
+        for (String path : new String[] {"a.", ".a", "a..b", "", "o.", 
"o..b"}) {
+            assertThatThrownBy(() -> memory.set(path, 2))
+                    .as("set(\"%s\")", path)
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessage("Cannot write field '" + path + "': path has 
an empty component.");
+        }
+        MemoryObject nested = memory.get("o");
+        assertThatThrownBy(() -> nested.set("", 2))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("Cannot write field 'o.': path has an empty 
component.");
+
+        assertMemoryHoldsOnlyValueAAndEmptyObjectO();
+    }
+
+    @Test
+    void testNewObjectWithEmptyPathComponentIsRejectedWithoutSideEffects() 
throws Exception {
+        memory.set("a", 1);
+        memory.newObject("o");
+
+        for (String path : new String[] {"a.", ".a", "a..b", "", "o.", 
"o..b"}) {
+            for (boolean overwrite : new boolean[] {false, true}) {
+                assertThatThrownBy(() -> memory.newObject(path, overwrite))
+                        .as("newObject(\"%s\", %s)", path, overwrite)
+                        .isInstanceOf(IllegalArgumentException.class)
+                        .hasMessage(
+                                "Cannot write field '" + path + "': path has 
an empty component.");
+            }
+        }
+        MemoryObject nested = memory.get("o");
+        assertThatThrownBy(() -> nested.newObject("", true))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessage("Cannot write field 'o.': path has an empty 
component.");
+
+        assertMemoryHoldsOnlyValueAAndEmptyObjectO();
+    }
+
+    private void assertMemoryHoldsOnlyValueAAndEmptyObjectO() throws Exception 
{
+        for (String path : new String[] {"a.", ".a", "a..b", "o.", "o..b"}) {
+            assertFalse(memory.isExist(path), path);
+        }
+        assertEquals(1, memory.get("a").getValue());
+        assertThat(memory.get("o").getFields()).isEmpty();
+        assertThat(memory.getFields())
+                .containsOnly(Map.entry("a", 1), Map.entry("o", 
"NestedObject"));
+        assertThat(memoryUpdates)
+                .containsExactly(new MemoryUpdate("a", 1), new 
MemoryUpdate("o", null, true));
+    }
+
     @Test
     void testIsExist() throws Exception {
         memory.set("exist", 1);
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/memory/MemoryUpdateReplayerTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/memory/MemoryUpdateReplayerTest.java
index cadb6a9f2..34edfb89d 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/memory/MemoryUpdateReplayerTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/memory/MemoryUpdateReplayerTest.java
@@ -133,6 +133,31 @@ public class MemoryUpdateReplayerTest {
         assertThat(restored.get("slot.child").getValue()).isEqualTo(2);
     }
 
+    @Test
+    void testReplayOfActionThatCaughtRejectedNewObjectUnderValue() throws 
Exception {
+        // The action writes a value, then fails to create an object below it, 
catches the
+        // exception and completes. The rejected newObject must not be 
recorded, otherwise replay
+        // re-applies it and fails recovery.
+        List<MemoryUpdate> updates =
+                recordUpdates(
+                        memory -> {
+                            memory.set("a", 1);
+                            try {
+                                memory.newObject("a.b");
+                            } catch (IllegalArgumentException | 
UnsupportedOperationException e) {
+                                // swallowed by the action
+                            }
+                        });
+
+        assertThat(updates).containsExactly(new MemoryUpdate("a", 1));
+
+        MemoryObject restored = freshMemory(new LinkedList<>());
+        assertThatCode(() -> MemoryUpdateReplayer.replay(restored, updates))
+                .doesNotThrowAnyException();
+        assertThat(restored.get("a").getValue()).isEqualTo(1);
+        assertThat(restored.isExist("a.b")).isFalse();
+    }
+
     @Test
     void testReplayPreservesUserNullValueWrite() throws Exception {
         // A user's set(path, null) is a value write, not an object creation; 
replay must keep it
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/MemoryWriteBelowValueRecoveryTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/MemoryWriteBelowValueRecoveryTest.java
new file mode 100644
index 000000000..6274cb1fc
--- /dev/null
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/MemoryWriteBelowValueRecoveryTest.java
@@ -0,0 +1,235 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.flink.agents.runtime.operator;
+
+import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.InputEvent;
+import org.apache.flink.agents.api.OutputEvent;
+import org.apache.flink.agents.api.context.MemoryObject;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.plan.AgentConfiguration;
+import org.apache.flink.agents.plan.AgentPlan;
+import org.apache.flink.agents.plan.JavaFunction;
+import org.apache.flink.agents.plan.actions.Action;
+import org.apache.flink.agents.runtime.actionstate.InMemoryActionStateStore;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.java.functions.KeySelector;
+import org.apache.flink.runtime.checkpoint.OperatorSubtaskState;
+import org.apache.flink.runtime.state.hashmap.HashMapStateBackend;
+import org.apache.flink.state.rocksdb.EmbeddedRocksDBStateBackend;
+import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+import org.apache.flink.streaming.util.KeyedOneInputStreamOperatorTestHarness;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/**
+ * Verifies, against real keyed state and across a snapshot / restore cycle, 
that a write below a
+ * value field is rejected without side effects, and that an action which 
catches such a rejection
+ * can be replayed from its recorded memory updates on recovery.
+ */
+public class MemoryWriteBelowValueRecoveryTest {
+
+    private static final KeySelector<Long, Long> CONSTANT_KEY = value -> 0L;
+
+    private static final long SET_VALUE = 1L;
+    private static final long SET_GRANDCHILD_UNDER_VALUE = 2L;
+    private static final long NEW_OBJECT_UNDER_VALUE = 3L;
+    private static final long INSPECT = 4L;
+
+    private static final AtomicInteger CALLS = new AtomicInteger();
+    private static final AtomicReference<Throwable> REJECTION = new 
AtomicReference<>();
+    private static final AtomicReference<Map<String, Object>> INSPECTION = new 
AtomicReference<>();
+
+    /** Test agent; the input value selects the step. */
+    public static class WriteBelowValueAgent {
+        public static void step(Event event, RunnerContext ctx) throws 
Exception {
+            CALLS.incrementAndGet();
+            long step = (Long) InputEvent.fromEvent(event).getInput();
+            MemoryObject stm = ctx.getShortTermMemory();
+            if (step == SET_VALUE) {
+                stm.set("a", 1);
+            } else if (step == SET_GRANDCHILD_UNDER_VALUE) {
+                try {
+                    stm.set("a.b.c", 2);
+                } catch (Exception e) {
+                    REJECTION.set(e);
+                }
+            } else if (step == NEW_OBJECT_UNDER_VALUE) {
+                try {
+                    stm.newObject("a.b");
+                } catch (Exception e) {
+                    REJECTION.set(e);
+                }
+            } else if (step == INSPECT) {
+                Map<String, Object> seen = new HashMap<>();
+                seen.put("fields", stm.getFields());
+                seen.put("a.value", stm.get("a").getValue());
+                seen.put("a.fields", stm.get("a").getFields());
+                seen.put("a.b exists", stm.isExist("a.b"));
+                seen.put("a.b.c exists", stm.isExist("a.b.c"));
+                INSPECTION.set(seen);
+            }
+            ctx.sendEvent(new OutputEvent(step));
+        }
+    }
+
+    @BeforeEach
+    void reset() {
+        CALLS.set(0);
+        REJECTION.set(null);
+        INSPECTION.set(null);
+    }
+
+    private static AgentPlan plan() throws Exception {
+        Action step =
+                new Action(
+                        "step",
+                        new JavaFunction(
+                                WriteBelowValueAgent.class,
+                                "step",
+                                new Class<?>[] {Event.class, 
RunnerContext.class}),
+                        Collections.singletonList(InputEvent.EVENT_TYPE));
+        Map<String, Action> actions = new HashMap<>();
+        actions.put(step.getName(), step);
+        return new AgentPlan(actions, new HashMap<>(), new 
AgentConfiguration());
+    }
+
+    private static KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
openHarness(
+            String backendName, InMemoryActionStateStore store, 
OperatorSubtaskState snapshot)
+            throws Exception {
+        KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> harness =
+                new KeyedOneInputStreamOperatorTestHarness<>(
+                        store == null
+                                ? new ActionExecutionOperatorFactory<>(plan(), 
true)
+                                : new ActionExecutionOperatorFactory<>(plan(), 
true, store),
+                        CONSTANT_KEY,
+                        TypeInformation.of(Long.class));
+        boolean rocksDb = "rocksdb".equals(backendName);
+        harness.setStateBackend(
+                rocksDb ? new EmbeddedRocksDBStateBackend() : new 
HashMapStateBackend());
+        if (snapshot != null) {
+            harness.initializeState(snapshot);
+        }
+        harness.open();
+        
assertThat(operator(harness).getKeyedStateBackend().getClass().getSimpleName())
+                .isEqualTo(rocksDb ? "RocksDBKeyedStateBackend" : 
"HeapKeyedStateBackend");
+        return harness;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static ActionExecutionOperator<Long, Object> operator(
+            KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness) {
+        return (ActionExecutionOperator<Long, Object>) harness.getOperator();
+    }
+
+    private static void process(
+            KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness, long step)
+            throws Exception {
+        harness.processElement(new StreamRecord<>(step));
+        operator(harness).waitInFlightEventsFinished();
+    }
+
+    /** Runs the inspection action and asserts that memory holds only the 
value {@code a}. */
+    private static void assertMemoryHoldsOnlyValueA(
+            KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness) throws Exception {
+        INSPECTION.set(null);
+        process(harness, INSPECT);
+        Map<String, Object> expected = new HashMap<>();
+        expected.put("fields", Collections.singletonMap("a", 1));
+        expected.put("a.value", 1);
+        expected.put("a.fields", Collections.emptyMap());
+        expected.put("a.b exists", false);
+        expected.put("a.b.c exists", false);
+        assertThat(INSPECTION.get()).isEqualTo(expected);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"hashmap", "rocksdb"})
+    void rejectedWriteBelowValueLeavesNoOrphanAcrossRestore(String 
backendName) throws Exception {
+        OperatorSubtaskState snapshot;
+        try (KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness =
+                openHarness(backendName, null, null)) {
+            process(harness, SET_VALUE);
+
+            process(harness, SET_GRANDCHILD_UNDER_VALUE);
+            assertThat(REJECTION.get())
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessage("Cannot write field 'a.b.c': 'a' exists but is 
not an object.");
+            assertMemoryHoldsOnlyValueA(harness);
+
+            REJECTION.set(null);
+            process(harness, NEW_OBJECT_UNDER_VALUE);
+            assertThat(REJECTION.get())
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessage("Cannot write field 'a.b': 'a' exists but is 
not an object.");
+            assertMemoryHoldsOnlyValueA(harness);
+
+            snapshot = harness.snapshot(1L, 1L);
+        }
+
+        try (KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness =
+                openHarness(backendName, null, snapshot)) {
+            assertMemoryHoldsOnlyValueA(harness);
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"hashmap", "rocksdb"})
+    void replayOfActionThatCaughtRejectedNewObjectCompletesRecovery(String 
backendName)
+            throws Exception {
+        InMemoryActionStateStore store = new InMemoryActionStateStore(false);
+        OperatorSubtaskState snapshot;
+        try (KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness =
+                openHarness(backendName, store, null)) {
+            process(harness, SET_VALUE);
+            snapshot = harness.snapshot(1L, 1L);
+            // Completes after the checkpoint, so its ActionState survives the 
"failure" below
+            // while the memory state it saw is rolled back to the checkpoint.
+            process(harness, NEW_OBJECT_UNDER_VALUE);
+            assertThat(REJECTION.get()).as("the write below a value is 
rejected").isNotNull();
+            assertThat(CALLS.get()).isEqualTo(2);
+        }
+
+        // Recovery: the completed action is replayed from its recorded memory 
updates. The
+        // rejected newObject must not be among them, otherwise replay throws 
and the input
+        // fails on every recovery attempt.
+        try (KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness =
+                openHarness(backendName, store, snapshot)) {
+            assertThatCode(() -> process(harness, NEW_OBJECT_UNDER_VALUE))
+                    .as("replaying the completed action must not fail 
recovery")
+                    .doesNotThrowAnyException();
+            assertThat(CALLS.get())
+                    .as("completed action must be replayed, not re-executed")
+                    .isEqualTo(2);
+            assertThat(harness.getRecordOutput()).hasSize(1);
+            // The inspection is a new input (next sequence number), so it 
executes rather than
+            // being replayed from a retained ActionState.
+            assertMemoryHoldsOnlyValueA(harness);
+        }
+    }
+}

Reply via email to