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

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new d1ac8dc3b7 fix(amber): pass unstamped boundary states through LoopEnd 
instead of consuming them (#6913)
d1ac8dc3b7 is described below

commit d1ac8dc3b76d3248bebc5c289ece6aa8c84f0766
Author: Xinyuan Lin <[email protected]>
AuthorDate: Sun Aug 9 21:52:52 2026 -0700

    fix(amber): pass unstamped boundary states through LoopEnd instead of 
consuming them (#6913)
    
    ### What changes were proposed in this PR?
    
    Follow-up to #6661, addressing [this review
    comment](https://github.com/apache/texera/pull/6661#discussion_r3648708075):
    a loop-body operator that emits its own boundary state
    (`produce_state_on_start/finish` — a public API on both engine sides)
    sends it with the "no loop" envelope (counter `0`, `loop_start_id ""`).
    The LoopEnd matching branch treated **every** counter-0 frame as the
    loop's own boundary state:
    
    ```
    LoopStart ──(0, LS-id)──▶ stateful body op ──▶ LoopEnd
                                 │ produce_state_on_finish
                                 └──(0, "")────────▶ LoopEnd   ← arrives AFTER 
the loop state
    ```
    
    Consuming the unstamped state (1) clobbers the captured back-jump id
    with `""` → `no loop-back state URI configured for LoopStart ''`, and
    (2) hands `run_update` a State with no `table` payload → `KeyError`.
    Either way the loop breaks. The reviewer reasoned this for a stateful
    JVM operator; it is language-independent (a Python UDF hits the
    identical path — no JVM built-in currently overrides
    `produceStateOnFinish`, so the Python UDF is also the practical repro).
    
    **Fix (consumer-side).** A real loop state is always stamped — the
    matching LoopStart stamps its own id on every iteration's output state —
    so the LoopEnd runtime now keys on the stamp:
    
    | Frame at LoopEnd (counter 0) | before | after |
    |---|---|---|
    | stamped (`loop_start_id` set) | consume + capture id | unchanged |
    | unstamped (`""`) | consume → id clobber / `KeyError` | forward
    downstream unchanged, skip the operator (default pass-through
    semantics), captured id untouched |
    
    **Keeping the loud failure.** Forwarding unstamped frames also swallows
    the symptom of the bug class #6660/#6661 fixed: a hop that blanks the
    envelope makes the loop's *own* state arrive unstamped, and forwarding
    it leaves `_loop_table` `None` → `condition()` returns `False` → the
    loop stops after one iteration and reports **success with a wrong row
    count**. So a Loop End that forwarded an unstamped state and never took
    a stamped one now raises:
    
    ```
    Loop End received a loop-boundary state with no LoopStart stamp and never
    received its own (stamped) loop state: the loop envelope was lost upstream,
    so this loop would silently stop after one iteration
    ```
    
    Three properties of where that check lives:
    
    - **`_process_end_channel`, not `complete()`** — `complete()` runs after
    `port_completed` has gone out for the input port and every output port,
    and region completion is port-based, so a raise there would be reported
    only once the coordinator already considers the region done.
    - **Order-independent** — the body operator's state may arrive before or
    after the loop's; `EndChannel` is `PORT_ALIGNMENT`, so the port is
    drained by then.
    - **Reads nothing out of the `State`** — deliberately not keyed on the
    reserved `table` key, which #6971 removes from the loop state (and which
    an ordinary body UDF may legitimately emit), so the guard cannot rot
    green.
    
    It is narrow on purpose — *forwarded an unstamped state* **and** *never
    took a stamped one*. A Loop End completing without any matching state is
    legal (`LoopEndOperator.eval_condition`'s `_loop_table` guard), so only
    positive evidence of an unstamped boundary state counts.
    
    Also documents why a Loop **Start** must do the opposite — MERGE an
    unstamped counter-0 state rather than forward it: the back-edge writes
    the next iteration's variables to the Loop Start's own input-port state
    URI with that same "no loop" envelope (`State.to_tuple(0)`), so a Loop
    Start cannot tell its own state from an upstream operator's, while a
    Loop End can. The key-collision hazard that follows from the merge is
    filed as #7248.
    
    ### Any related issues, documentation, discussions?
    
    Follow-up to #6661 (review discussion r3648708075). Related engine
    context: #6660. Follow-up filed: #7248.
    
    ### How was this PR tested?
    
    - **Unit** (`test_main_loop.py`), all verified red before the
    corresponding change:
    - an unstamped counter-0 frame at a LoopEnd is forwarded with its
    envelope unchanged, the operator is not invoked, the captured back-jump
    id is not clobbered, and it does not count as taking the loop's own
    state;
    - a LoopEnd that only ever saw an unstamped state reports the error from
    `_process_end_channel` and sends **no** `port_completed`, so the region
    is held;
    - the legitimate shape (body-operator state *plus* the loop's own
    stamped state) stays silent in **both** arrival orders;
    - an unstamped counter-0 frame at a Loop **Start** is merged, not
    forwarded.
    - **E2E** (`LoopIntegrationSpec`, CI-only): `TextInput → LoopStart →
    stateful Python UDF → LoopEnd` where the UDF emits boundary state via
    `produce_state_on_finish` — it crashes without this fix and completes
    exactly 3 iterations with it.
    - Full pyamber suite, `scalafmtCheckAll` + `scalafixAll --check` + full
    test-compile + ruff format/check pass locally (Java 17).
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
    
    ---------
    
    Co-authored-by: Yicong Huang 
<[email protected]>
---
 amber/src/main/python/core/runnables/main_loop.py  |  90 ++++++++++-
 .../worker/promisehandlers/EndChannelHandler.scala |   3 +
 .../promisehandlers/StartChannelHandler.scala      |   3 +
 .../amber/engine/e2e/LoopIntegrationSpec.scala     |  51 ++++++
 .../test/python/core/runnables/test_main_loop.py   | 179 +++++++++++++++++++++
 5 files changed, 323 insertions(+), 3 deletions(-)

diff --git a/amber/src/main/python/core/runnables/main_loop.py 
b/amber/src/main/python/core/runnables/main_loop.py
index fe44b36044..048bc0a289 100644
--- a/amber/src/main/python/core/runnables/main_loop.py
+++ b/amber/src/main/python/core/runnables/main_loop.py
@@ -93,6 +93,11 @@ class MainLoop(StoppableQueueBlockingRunnable):
         # same iteration's state arrives once per branch. Workers are recreated
         # on each region re-execution, so this instance flag is per iteration.
         self._loop_state_consumed: bool = False
+        # Whether this LoopEnd forwarded an UNstamped counter-0 state instead
+        # of consuming it. Paired with _loop_start_id by
+        # _check_loop_state_arrived: forwarding one and never capturing a stamp
+        # means the loop's own state reached here with its stamp lost.
+        self._forwarded_unstamped_state: bool = False
 
         self.context = Context(worker_id, input_queue)
         self._async_rpc_server = AsyncRPCServer(output_queue, 
context=self.context)
@@ -127,6 +132,48 @@ class MainLoop(StoppableQueueBlockingRunnable):
         writer.put_one(executor.state.to_tuple(0))
         writer.close()
 
+    def _check_loop_state_arrived(self) -> None:
+        # Keep the LOUD failure for a real loop state that lost its stamp
+        # upstream. Forwarding unstamped states (above) is right for a body
+        # operator's own boundary state, but it also swallows the symptom of
+        # the bug class #6660/#6661 fixed: a hop that blanks the envelope makes
+        # the loop's own state arrive unstamped, and forwarding it leaves
+        # _loop_table None, so condition() returns False and the loop stops
+        # after one iteration -- a WRONG RESULT reported as success.
+        #
+        # The two cases are told apart once the input port is done rather than
+        # on arrival: a LoopEnd that forwarded an unstamped state and never
+        # took a stamped one cannot have been looking at a body operator's
+        # boundary state -- its own state never arrived. Deciding at EndChannel
+        # is order-independent (the body operator's state may arrive before or
+        # after the loop's; EndChannel is PORT_ALIGNMENT, so every channel on
+        # the port has been drained) and reads nothing out of the State, so it
+        # keeps working once the input table stops riding inside it (#6971).
+        #
+        # Called from _process_end_channel, NOT complete(): complete() runs
+        # after port_completed has gone out for the input port and every output
+        # port, and region completion is port-based, so a raise there would be
+        # reported only once the coordinator already considers the region done.
+        #
+        # The check is deliberately narrow -- "forwarded an unstamped state AND
+        # never took a stamped one", not "never took a stamped one". A LoopEnd
+        # completing without any matching state is legal (see
+        # LoopEndOperator.eval_condition's _loop_table guard); only positive
+        # evidence that a boundary state arrived unstamped is a lost envelope.
+        #
+        # "Took a stamped one" is read off _loop_start_id, which only the
+        # stamped branch writes and nothing clears -- NOT off the
+        # _loop_state_consumed fan-in dedup flag, whose lifetime is owned by
+        # the dedup and may end before this runs. Keying on the durable field
+        # keeps this guard correct wherever it is called from.
+        if self._forwarded_unstamped_state and not self._loop_start_id:
+            raise RuntimeError(
+                "Loop End received a loop-boundary state with no LoopStart "
+                "stamp and never received its own (stamped) loop state: the "
+                "loop envelope was lost upstream, so this loop would silently "
+                "stop after one iteration"
+            )
+
     def complete(self) -> None:
         """
         Complete the DataProcessor, marking state to COMPLETED, and notify the
@@ -379,10 +426,42 @@ class MainLoop(StoppableQueueBlockingRunnable):
             self._check_and_process_control()
             return
 
+        # A LoopStart handles only the STAMPED case above. An UNstamped
+        # counter-0 state at a LoopStart takes neither branch: it falls all the
+        # way through to process_input_state at the bottom, and the operator's
+        # process_state MERGES its keys into the loop variables. That is the
+        # opposite of what the LoopEnd branch below does with the identical
+        # frame, and the asymmetry is forced, not an oversight. The back-edge
+        # writes the next iteration's variables to the LoopStart's own
+        # input-port state URI with that very same "no loop" envelope
+        # (_jump_to_loop_start -> State.to_tuple(0)), so an unstamped counter-0
+        # frame at a LoopStart is indistinguishable from -- and normally IS --
+        # the loop's own state. A LoopEnd may forward instead of consuming
+        # because its inbound loop state is always stamped by the matching
+        # LoopStart; a LoopStart has no such signal. Consequence of the merge:
+        # an upstream or body operator emitting a key that collides with a loop
+        # variable overwrites it, and one emitting `table` trips
+        # _reserved_name_error in produce_state_on_finish (see #7248).
+
         if isinstance(executor, LoopEndOperator):
-            # Matching LoopEnd (in_counter == 0): it will consume this state
-            # and jump back. Remember which LoopStart to jump to (it rides
-            # the envelope) for complete()/_jump_to_loop_start.
+            if not frame.loop_start_id:
+                # An UNstamped counter-0 state at a LoopEnd is not the loop's
+                # own boundary state -- it was produced by a loop-body
+                # operator's produce_state_on_start/finish (a public API on
+                # both engine sides), which emits with the "no loop" envelope.
+                # A real loop state is always stamped: the matching LoopStart
+                # stamps its own id on every iteration's output state.
+                # Forward it downstream unchanged, skipping the operator, like
+                # any default pass-through: consuming it would clobber the
+                # captured back-jump id with "" and hand run_update a State
+                # with no `table` payload.
+                self._forwarded_unstamped_state = True
+                self._emit_and_save_state(state, in_counter, 
frame.loop_start_id)
+                self._check_and_process_control()
+                return
+            # Matching LoopEnd (in_counter == 0, stamped): it will consume this
+            # state and jump back. Remember which LoopStart to jump to (it
+            # rides the envelope) for complete()/_jump_to_loop_start.
             #
             # With a branching loop body, each branch's reader replays the same
             # iteration's state, so this fires once per inbound link. Consume
@@ -412,6 +491,11 @@ class MainLoop(StoppableQueueBlockingRunnable):
 
     def _process_end_channel(self) -> None:
         self.process_input_state()
+        try:
+            self._check_loop_state_arrived()
+        except Exception as err:
+            self.context.report_exception(err)
+            self._check_exception()
         if self.context.exception_manager.has_exception():
             # A state-emission error was reported on the main loop thread (see
             # _emit_and_save_state). Hold the region: skip port_completed and
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/EndChannelHandler.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/EndChannelHandler.scala
index 7794342690..f1b37d7d32 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/EndChannelHandler.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/EndChannelHandler.scala
@@ -43,6 +43,9 @@ trait EndChannelHandler {
     try {
       val outputState = dp.executor.produceStateOnFinish(portId.id)
       if (outputState.isDefined) {
+        // Operator-ORIGINATED boundary state, so no LoopStart stamp
+        // (loopCounter = 0, loopStartId = ""); see
+        // `main_loop._process_state_frame` for how a Loop End treats it.
         dp.outputManager.emitState(outputState.get)
       }
       dp.outputManager.outputIterator.setTupleOutput(
diff --git 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/StartChannelHandler.scala
 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/StartChannelHandler.scala
index 01cbb858bd..84de874aa6 100644
--- 
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/StartChannelHandler.scala
+++ 
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/promisehandlers/StartChannelHandler.scala
@@ -42,6 +42,9 @@ trait StartChannelHandler {
     try {
       val outputState = dp.executor.produceStateOnStart(portId.id)
       if (outputState.isDefined) {
+        // Operator-ORIGINATED boundary state, so no LoopStart stamp
+        // (loopCounter = 0, loopStartId = ""); see
+        // `main_loop._process_state_frame` for how a Loop End treats it.
         dp.outputManager.emitState(outputState.get)
       }
     } catch safely {
diff --git 
a/amber/src/test/integration/org/apache/texera/amber/engine/e2e/LoopIntegrationSpec.scala
 
b/amber/src/test/integration/org/apache/texera/amber/engine/e2e/LoopIntegrationSpec.scala
index 858e10e20e..9da249f996 100644
--- 
a/amber/src/test/integration/org/apache/texera/amber/engine/e2e/LoopIntegrationSpec.scala
+++ 
b/amber/src/test/integration/org/apache/texera/amber/engine/e2e/LoopIntegrationSpec.scala
@@ -45,6 +45,7 @@ import org.apache.texera.amber.operator.limit.LimitOpDesc
 import org.apache.texera.amber.operator.loop.{LoopEndOpDesc, LoopStartOpDesc}
 import org.apache.texera.amber.operator.sleep.SleepOpDesc
 import org.apache.texera.amber.operator.source.scan.text.TextInputSourceOpDesc
+import org.apache.texera.amber.operator.udf.python.PythonUDFOpDescV2
 import org.apache.texera.amber.tags.IntegrationTest
 import org.apache.texera.common.compiler.model.LogicalLink
 import org.scalatest.flatspec.AnyFlatSpecLike
@@ -195,6 +196,29 @@ class LoopIntegrationSpec
   // any loop logic runs. Production workers launch with a real classpath,
   // so this is a harness limitation, not an engine one.
 
+  /** A pass-through Python UDF that ALSO emits its own boundary state via the
+    * public `produce_state_on_finish` API -- the state arrives downstream
+    * with the "no loop" envelope (counter 0, no LoopStart stamp).
+    */
+  private def statefulPythonUDF(): PythonUDFOpDescV2 = {
+    val op = new PythonUDFOpDescV2()
+    op.code = """from pytexera import *
+                |
+                |class ProcessTupleOperator(UDFOperatorV2):
+                |
+                |    @overrides
+                |    def process_tuple(self, tuple_: Tuple, port: int) -> 
Iterator[Optional[TupleLike]]:
+                |        yield tuple_
+                |
+                |    @overrides
+                |    def produce_state_on_finish(self, port: int) -> 
Optional[State]:
+                |        return State({"note": "from-body-op"})
+                |""".stripMargin
+    op.workers = 1
+    op.retainInputColumns = true
+    op
+  }
+
   private def link(from: LogicalOp, to: LogicalOp): LogicalLink =
     LogicalLink(from.operatorIdentifier, PortIdentity(), 
to.operatorIdentifier, PortIdentity())
 
@@ -342,4 +366,31 @@ class LoopIntegrationSpec
     )
   }
 
+  it should "run a loop whose body operator emits its own boundary state" in {
+    // TextInput -> LoopStart -> stateful Python UDF -> LoopEnd.
+    //
+    // The UDF emits boundary state via produce_state_on_finish (a public API
+    // on both engine sides), which reaches the LoopEnd with the "no loop"
+    // envelope (counter 0, no LoopStart stamp) AFTER the forwarded loop
+    // state. The LoopEnd must pass that unstamped state through instead of
+    // consuming it: consuming would clobber the captured back-jump id with ""
+    // ("no loop-back state URI configured for LoopStart ''") and hand
+    // run_update a State with no `table` payload (KeyError). Regression test
+    // for #discussion_r3648708075 on #6661.
+    val src = textInput("1\n2\n3")
+    val start = loopStart("i = 0", "table.iloc[i]")
+    val mid = statefulPythonUDF()
+    val end = loopEnd("i += 1", "i < len(table)")
+    val materialized = runAndGetMaterializedRowCounts(
+      List(src, start, mid, end),
+      List(link(src, start), link(start, mid), link(mid, end))
+    )
+    val endRows = materialized.getOrElse(end.operatorIdentifier, -1L)
+    assert(
+      endRows == 3,
+      s"LoopEnd must accumulate all 3 iterations with a state-emitting " +
+        s"operator in the loop body: expected 3, got $endRows (all: 
$materialized)"
+    )
+  }
+
 }
diff --git a/amber/src/test/python/core/runnables/test_main_loop.py 
b/amber/src/test/python/core/runnables/test_main_loop.py
index 73f3957559..1da2d19691 100644
--- a/amber/src/test/python/core/runnables/test_main_loop.py
+++ b/amber/src/test/python/core/runnables/test_main_loop.py
@@ -2416,6 +2416,44 @@ class TestMainLoop:
         assert emitted_id == "outer-loop"
         assert reset_calls == [], "a LoopStart never resets output storage"
 
+    def test_loopstart_merges_unstamped_state_instead_of_forwarding_it(
+        self, main_loop, monkeypatch
+    ):
+        # The deliberate asymmetry with the LoopEnd branch: an UNstamped
+        # counter-0 frame at a LoopStart is MERGED into the loop variables,
+        # not forwarded. It has to be -- the back-edge writes the next
+        # iteration's variables to this LoopStart's own input-port state URI
+        # with the identical "no loop" envelope (State.to_tuple(0)), so a
+        # LoopStart cannot tell the loop's own state from an upstream/body
+        # operator's boundary state. A LoopEnd can, because its inbound loop
+        # state is always stamped.
+        class StubLoopStart(LoopStartOperator):
+            def process_table(self, table, port):
+                yield
+
+        executor = StubLoopStart()
+        main_loop.context.executor_manager.executor = executor
+        emitted, switched, reset_calls = self._capture_state_emit(
+            main_loop, monkeypatch
+        )
+        monkeypatch.setattr(
+            main_loop.context.state_processing_manager,
+            "get_output_state",
+            lambda: None,
+        )
+
+        main_loop._process_state_frame(
+            StateFrame(State({"seed": 7}), loop_counter=0, loop_start_id="")
+        )
+
+        assert emitted == [], "an unstamped state at a LoopStart is not 
forwarded"
+        assert switched == [True], "it reaches the operator, which merges it"
+        assert reset_calls == []
+        # It is handed to the operator as the current input state, so
+        # LoopStartOperator.process_state merges it into self.state.
+        passed = main_loop.context.state_processing_manager.current_input_state
+        assert passed == State({"seed": 7})
+
     def test_loopend_passthrough_decrements_resets_output_and_skips_operator(
         self, main_loop, monkeypatch
     ):
@@ -2495,6 +2533,147 @@ class TestMainLoop:
         assert "loop_start_id" not in passed_to_operator
         assert "loop_counter" not in passed_to_operator
 
+    def test_loopend_forwards_unstamped_state_without_consuming(
+        self, main_loop, monkeypatch
+    ):
+        # A loop-body operator that emits its own boundary state
+        # (produce_state_on_start/finish -- a public API on both engine sides)
+        # sends it with the "no loop" envelope (counter 0, id ""). That state
+        # is NOT the loop's own boundary state: consuming it would clobber the
+        # captured back-jump id with "" and hand run_update a State with no
+        # `table` payload (KeyError). A real loop state is always stamped --
+        # the matching LoopStart stamps its own id on every iteration's output
+        # -- so an UNstamped counter-0 frame at a LoopEnd must be forwarded
+        # downstream unchanged, skipping the operator, like any default
+        # pass-through.
+        main_loop.context.executor_manager.executor = _FalseLoopEnd()
+        emitted, switched, reset_calls = self._capture_state_emit(
+            main_loop, monkeypatch
+        )
+        # The loop's own state was already consumed and its id captured.
+        main_loop._loop_start_id = "loop-start-1"
+
+        main_loop._process_state_frame(
+            StateFrame(
+                State({"note": "from-body-op"}),
+                loop_counter=0,
+                loop_start_id="",
+            )
+        )
+
+        assert switched == [], "unstamped state must not invoke the operator"
+        assert reset_calls == [], "unstamped state must not reset output"
+        assert emitted == [(State({"note": "from-body-op"}), 0, "")], (
+            "unstamped state must forward downstream with its envelope "
+            f"unchanged; emitted: {emitted}"
+        )
+        assert main_loop._loop_start_id == "loop-start-1", (
+            "an unstamped state must not clobber the captured back-jump id"
+        )
+        # Forwarding an unstamped state must NOT count as taking the loop's
+        # own state -- that is what the completion guard keys on.
+        assert main_loop._loop_state_consumed is False
+
+    @pytest.mark.timeout(2)
+    def test_end_channel_holds_the_region_when_only_unstamped_states_arrived(
+        self, main_loop, monkeypatch
+    ):
+        # Forwarding unstamped states is right for a body operator's own
+        # boundary state, but it must not swallow the symptom of a LOST stamp:
+        # a hop that blanks the envelope (the bug class #6660/#6661 fixed)
+        # makes the loop's OWN state arrive unstamped, and forwarding it leaves
+        # _loop_table None -> condition() False -> one iteration reported as
+        # success. A Loop End that forwarded an unstamped state and never took
+        # a stamped one must fail loudly instead -- and it must do so from
+        # _process_end_channel, BEFORE port_completed goes out, because region
+        # completion is port-based: reported from complete() the error would
+        # arrive after the coordinator already considers the region done.
+        executor = _FalseLoopEnd()
+        main_loop.context.executor_manager.executor = executor
+        self._capture_state_emit(main_loop, monkeypatch)
+
+        completed = []
+        port_completed_calls = []
+        console_msgs = []
+        monkeypatch.setattr(main_loop, "process_input_tuple", lambda: None)
+        monkeypatch.setattr(main_loop, "complete", lambda: 
completed.append(True))
+        monkeypatch.setattr(
+            main_loop, "_send_console_message", lambda msg: 
console_msgs.append(msg)
+        )
+        monkeypatch.setattr(
+            main_loop.context.pause_manager,
+            "pause",
+            lambda pause_type, change_state=True: None,
+        )
+
+        class _Coordinator:
+            def port_completed(self, request):
+                port_completed_calls.append(request)
+
+        monkeypatch.setattr(
+            main_loop._async_rpc_client, "coordinator_stub", lambda: 
_Coordinator()
+        )
+
+        # The loop's own state arrives with its stamp lost upstream.
+        main_loop._process_state_frame(
+            StateFrame(State({"i": 0}), loop_counter=0, loop_start_id="")
+        )
+        assert main_loop._forwarded_unstamped_state is True
+        assert main_loop._loop_state_consumed is False
+
+        monkeypatch.setattr(main_loop, "process_input_state", lambda *a, **k: 
None)
+        main_loop._process_end_channel()
+
+        assert main_loop.context.exception_manager.has_exception()
+        error_msgs = [m for m in console_msgs if m.msg_type == 
ConsoleMessageType.ERROR]
+        assert len(error_msgs) == 1
+        assert "loop envelope was lost upstream" in error_msgs[0].title
+        assert port_completed_calls == [], "no port may be reported complete"
+        assert completed == [], "the worker must not complete"
+
+    def test_complete_accepts_unstamped_state_alongside_the_loop_state(
+        self, main_loop, monkeypatch
+    ):
+        # The legitimate shape this PR enables: a body operator's boundary
+        # state is forwarded AND the loop's own stamped state is consumed. The
+        # completion guard must stay silent, in either arrival order.
+        for body_state_first in (True, False):
+            executor = _FalseLoopEnd()
+            main_loop.context.executor_manager.executor = executor
+            main_loop._forwarded_unstamped_state = False
+            main_loop._loop_state_consumed = False
+            main_loop._loop_start_id = ""
+            self._capture_state_emit(main_loop, monkeypatch)
+            monkeypatch.setattr(
+                main_loop.context.state_processing_manager,
+                "get_output_state",
+                lambda: None,
+            )
+
+            body = StateFrame(
+                State({"note": "from-body-op"}), loop_counter=0, 
loop_start_id=""
+            )
+            loop = StateFrame(
+                State({"i": 0}), loop_counter=0, loop_start_id="outer-loop"
+            )
+            for frame in (body, loop) if body_state_first else (loop, body):
+                main_loop._process_state_frame(frame)
+
+            # Must not raise, in either order.
+            main_loop._check_loop_state_arrived()
+            assert main_loop._forwarded_unstamped_state is True
+            assert main_loop._loop_state_consumed is True
+            assert main_loop._loop_start_id == "outer-loop"
+
+            # The guard must key on the DURABLE evidence that a stamped state
+            # was taken (_loop_start_id), not on the fan-in dedup flag: that
+            # flag belongs to the dedup, and a caller may legitimately clear
+            # it once the iteration's state has been taken. Keyed on the flag,
+            # this legitimate shape would start raising the moment anything
+            # re-armed it -- so simulate that and require silence.
+            main_loop._loop_state_consumed = False
+            main_loop._check_loop_state_arrived()
+
     def test_loopend_consumes_its_loop_state_once_per_iteration(
         self, main_loop, monkeypatch
     ):

Reply via email to