weiqingy commented on code in PR #938:
URL: https://github.com/apache/flink-agents/pull/938#discussion_r3746302853


##########
runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java:
##########
@@ -403,6 +440,7 @@ private void processActionTaskForKey(Object key) throws 
Exception {
 
         boolean currentInputEventFinished = false;
         if (isFinished) {
+            notifyTaskFinished(actionTask);

Review Comment:
   This fires after the action is already recorded as done. 
`maybePersistTaskResult` has called `actionState.markCompleted()` 
(`DurableExecutionManager.java:269`), and the output events went downstream at 
line 437. So `checkEmpty` throwing here can't undo anything.
   
   On the restart, the action passes `actionState.isCompleted()` at line 379 
and gets skipped, so no handle is ever registered and the check quietly passes. 
The dropped call ends up skipped either way, just one job failure later.
   
   Would moving the check earlier help, right after `actionTask.invoke` returns 
at line 415?



##########
runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseAsyncSubagentSetup.java:
##########
@@ -0,0 +1,340 @@
+/*
+ * 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.subagent;
+
+import org.apache.flink.agents.api.context.DurableCallable;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.subagent.Result;
+import org.apache.flink.agents.api.subagent.SubagentFuture;
+
+import javax.annotation.Nullable;
+
+import java.util.concurrent.Callable;
+
+/**
+ * Production base for sub-agents whose protocol is an asynchronous job, run 
in pub/sub mode: {@code
+ * submit} (the pub) starts the run remotely through one durable POST and 
immediately returns a
+ * handle carrying the {@code (sessionId, callId)} identity; {@code isDone}, 
{@code await} and
+ * {@code cancel} on the handle (the sub) query or steer that run. The shape 
matches LangGraph runs,
+ * OpenAI Assistants runs, and A2A long-running tasks.
+ *
+ * <h2>Integration primitives</h2>
+ *
+ * <p>Integrations only provide the transport primitives they already 
understand, with no durable
+ * concepts involved:
+ *
+ * <ul>
+ *   <li>{@link #callSubmitRequest} — start the run remotely; a thrown 
exception fails the action;
+ *   <li>{@link #callQueryStatus} — a read-only probe of the run's current 
state;
+ *   <li>{@link #callFetchResult} — fetch the result of a run that reached a 
terminal state;
+ *   <li>{@link #callCancelRequest} — optional hook propagating a cancellation 
to the remote run.
+ * </ul>
+ *
+ * <h2>Persistence conventions</h2>
+ *
+ * <p>The framework wrappers decide which operation runs through durable 
execution:
+ *
+ * <ul>
+ *   <li>{@link #submitRequest} — durable, id {@code sessionId#callId}, the 
only wrapper wired to a
+ *       reconciler ({@link #reconcileSubmitRequest}), so the remote run is 
started at most once
+ *       even across a crash between the POST landing and its result being 
persisted;
+ *   <li>{@link #queryStatus} — not durable: a direct read-only probe on the 
mailbox thread. The
+ *       state advances monotonically toward a terminal state, so a replay 
observing a fresher state
+ *       is harmless;
+ *   <li>{@link #fetchResult} — durable, id {@code sessionId#callId#fetch}: 
the result enters the
+ *       caller's data flow and must replay deterministically. No reconciler; 
recovery re-executes
+ *       the fetch, which is an idempotent read;
+ *   <li>the await composition of {@code await} — durable, id {@code 
sessionId#callId#await}: poll
+ *       the status until a terminal state, then fetch;
+ *   <li>{@link #cancelRequest} — not durable: a direct, synchronous 
propagation. Remote
+ *       cancellations are expected to be idempotent, so a replay propagating 
the cancellation again
+ *       is harmless.
+ * </ul>
+ *
+ * <p>The fetch and await ids are fixed per identity: both compositions are 
built from idempotent
+ * reads, so a recovery re-executing them converges to the same outcome as the 
original run.
+ *
+ * <h2>Cancellation contract (dev-facing)</h2>
+ *
+ * <p>Cancel decisions typically depend on nondeterministic inputs such as 
processing time. A
+ * failover replay therefore does not promise control flow equivalent to the 
original execution: the
+ * original may have taken a cancel branch that the replay skips, or vice 
versa. The only
+ * at-most-once guarantee is the POST, enforced by the reconciler; 
cancellation propagation is
+ * best-effort and idempotent. The hook returns nothing: a cancelled {@code 
await} always fails as a
+ * {@link java.util.concurrent.CancellationException}, and a hook failure 
propagates from {@code
+ * cancel} and fails the action.
+ *
+ * <h2>Known limitations</h2>
+ *
+ * <ul>
+ *   <li>If the remote session or run record expires after a failover, the 
non-durable {@link
+ *       #queryStatus} may report a different state than before the crash, and 
a replay may not be
+ *       able to reproduce the original fetch path; persisted fetch records 
still short-circuit;
+ *   <li>If a fetch was in flight when the process crashed and the remote 
fetch is consume-once
+ *       rather than an idempotent read, the recovery re-execution cannot 
recover the result — a
+ *       reconciler cannot fix this; the remote protocol must guarantee 
idempotent reads;
+ *   <li>Any cancellation governs the subsequent {@code await} and may discard 
a fetch that had
+ *       actually succeeded, even when its durable record exists — cancel is 
the authoritative
+ *       control-flow decision.
+ * </ul>
+ */
+public abstract class BaseAsyncSubagentSetup extends BaseSubagentSetup {
+
+    /** Delay between status probes while waiting for the run to reach a 
terminal state. */
+    protected long statusPollIntervalMillis = 10;
+
+    // 
------------------------------------------------------------------------------------------
+    // pub: submit starts the run immediately through one durable POST
+    // 
------------------------------------------------------------------------------------------
+
+    /**
+     * Starts the remote run through the durable POST and returns its handle. 
A POST failure throws
+     * and fails the action.
+     */
+    @Override
+    public final SubagentFuture submit(
+            RunnerContext ctx, Object prompt, String sessionId, String callId) 
throws Exception {
+        ctx.durableExecuteAsync(submitRequest(ctx, sessionId, callId, prompt));
+        return new AsyncSubagentFuture(this, ctx, sessionId, callId, 
currentTaskRegistry());
+    }
+
+    // 
------------------------------------------------------------------------------------------
+    // Framework wrappers: defaults composing the primitives, overridable
+    // 
------------------------------------------------------------------------------------------
+
+    /** The durable POST of one invocation; the only wrapper wired to a 
reconciler. */
+    protected DurableCallable<Void> submitRequest(
+            RunnerContext ctx, String sessionId, String callId, Object prompt) 
{
+        return new DurableCallable<Void>() {
+            @Override
+            public String getId() {
+                return sessionId + "#" + callId;
+            }
+
+            @Override
+            public Class<Void> getResultClass() {
+                return Void.class;
+            }
+
+            @Override
+            public Void call() throws Exception {
+                callSubmitRequest(sessionId, callId, prompt);
+                return null;
+            }
+
+            @Override
+            public Callable<Void> reconciler() {
+                // Recovery never assumes the POST was lost: probe first, 
resend only a missing
+                // run, so a crash after the POST landed never duplicates the 
prompt.
+                return () -> {
+                    reconcileSubmitRequest(sessionId, callId, prompt);
+                    return null;
+                };
+            }
+        };
+    }
+
+    /** The status probe; not durable, a direct read-only query on the mailbox 
thread. */
+    protected RunStatus queryStatus(String sessionId, String callId) throws 
Exception {
+        return callQueryStatus(sessionId, callId);
+    }
+
+    /** The durable fetch of a terminal run's result, keyed by {@code 
sessionId#callId#fetch}. */
+    protected DurableCallable<Result> fetchResult(

Review Comment:
   Nothing in production calls this. `awaitResult` goes straight to 
`callFetchResult` at line 207, so the `#fetch` slot described at line 60 never 
gets written. The only caller is `fetchResultForTest` 
(`MockAsyncSubagentSetup.java:155`), and Python is the same shape: 
`fetch_result` (`async_subagent.py:286`) is test-only, and 
`_await_until_terminal` calls `call_fetch_result` at line 322.
   
   That also means the note at 86-89 about persisted fetch records 
short-circuiting can't happen, since nothing writes one.
   
   I can see why it went this way, `awaitResult.call()` runs off the mailbox so 
it can't call back into `durableExecuteAsync`. Is the plan to make the fetch 
slot reachable later, or would dropping it for now be cleaner?



##########
runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseAsyncSubagentSetup.java:
##########
@@ -0,0 +1,340 @@
+/*
+ * 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.subagent;
+
+import org.apache.flink.agents.api.context.DurableCallable;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.subagent.Result;
+import org.apache.flink.agents.api.subagent.SubagentFuture;
+
+import javax.annotation.Nullable;
+
+import java.util.concurrent.Callable;
+
+/**
+ * Production base for sub-agents whose protocol is an asynchronous job, run 
in pub/sub mode: {@code
+ * submit} (the pub) starts the run remotely through one durable POST and 
immediately returns a
+ * handle carrying the {@code (sessionId, callId)} identity; {@code isDone}, 
{@code await} and
+ * {@code cancel} on the handle (the sub) query or steer that run. The shape 
matches LangGraph runs,
+ * OpenAI Assistants runs, and A2A long-running tasks.
+ *
+ * <h2>Integration primitives</h2>
+ *
+ * <p>Integrations only provide the transport primitives they already 
understand, with no durable
+ * concepts involved:
+ *
+ * <ul>
+ *   <li>{@link #callSubmitRequest} — start the run remotely; a thrown 
exception fails the action;
+ *   <li>{@link #callQueryStatus} — a read-only probe of the run's current 
state;
+ *   <li>{@link #callFetchResult} — fetch the result of a run that reached a 
terminal state;
+ *   <li>{@link #callCancelRequest} — optional hook propagating a cancellation 
to the remote run.
+ * </ul>
+ *
+ * <h2>Persistence conventions</h2>
+ *
+ * <p>The framework wrappers decide which operation runs through durable 
execution:
+ *
+ * <ul>
+ *   <li>{@link #submitRequest} — durable, id {@code sessionId#callId}, the 
only wrapper wired to a
+ *       reconciler ({@link #reconcileSubmitRequest}), so the remote run is 
started at most once
+ *       even across a crash between the POST landing and its result being 
persisted;
+ *   <li>{@link #queryStatus} — not durable: a direct read-only probe on the 
mailbox thread. The
+ *       state advances monotonically toward a terminal state, so a replay 
observing a fresher state
+ *       is harmless;
+ *   <li>{@link #fetchResult} — durable, id {@code sessionId#callId#fetch}: 
the result enters the
+ *       caller's data flow and must replay deterministically. No reconciler; 
recovery re-executes
+ *       the fetch, which is an idempotent read;
+ *   <li>the await composition of {@code await} — durable, id {@code 
sessionId#callId#await}: poll
+ *       the status until a terminal state, then fetch;
+ *   <li>{@link #cancelRequest} — not durable: a direct, synchronous 
propagation. Remote
+ *       cancellations are expected to be idempotent, so a replay propagating 
the cancellation again
+ *       is harmless.
+ * </ul>
+ *
+ * <p>The fetch and await ids are fixed per identity: both compositions are 
built from idempotent
+ * reads, so a recovery re-executing them converges to the same outcome as the 
original run.
+ *
+ * <h2>Cancellation contract (dev-facing)</h2>
+ *
+ * <p>Cancel decisions typically depend on nondeterministic inputs such as 
processing time. A
+ * failover replay therefore does not promise control flow equivalent to the 
original execution: the
+ * original may have taken a cancel branch that the replay skips, or vice 
versa. The only
+ * at-most-once guarantee is the POST, enforced by the reconciler; 
cancellation propagation is
+ * best-effort and idempotent. The hook returns nothing: a cancelled {@code 
await} always fails as a
+ * {@link java.util.concurrent.CancellationException}, and a hook failure 
propagates from {@code
+ * cancel} and fails the action.
+ *
+ * <h2>Known limitations</h2>
+ *
+ * <ul>
+ *   <li>If the remote session or run record expires after a failover, the 
non-durable {@link
+ *       #queryStatus} may report a different state than before the crash, and 
a replay may not be
+ *       able to reproduce the original fetch path; persisted fetch records 
still short-circuit;
+ *   <li>If a fetch was in flight when the process crashed and the remote 
fetch is consume-once
+ *       rather than an idempotent read, the recovery re-execution cannot 
recover the result — a
+ *       reconciler cannot fix this; the remote protocol must guarantee 
idempotent reads;
+ *   <li>Any cancellation governs the subsequent {@code await} and may discard 
a fetch that had
+ *       actually succeeded, even when its durable record exists — cancel is 
the authoritative
+ *       control-flow decision.
+ * </ul>
+ */
+public abstract class BaseAsyncSubagentSetup extends BaseSubagentSetup {
+
+    /** Delay between status probes while waiting for the run to reach a 
terminal state. */
+    protected long statusPollIntervalMillis = 10;

Review Comment:
   nit: 10ms works out to roughly 100 status calls a second per run, against 
the services named at lines 34-35 (LangGraph, OpenAI Assistants, A2A), which 
don't usually finish that fast. Tests set it to 0 
(`MockAsyncSubagentSetup.java:71`), so integrations inherit this value. Would a 
bigger default, or a backoff, fit better?



##########
runtime/src/main/java/org/apache/flink/agents/runtime/subagent/SubagentFutureGroup.java:
##########
@@ -0,0 +1,100 @@
+/*
+ * 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.subagent;
+
+import org.apache.flink.agents.api.subagent.Result;
+import org.apache.flink.agents.api.subagent.SubagentFuture;
+import org.apache.flink.agents.api.subagent.SubagentFutures;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Batched resolve of several handles in submission order. The group knows 
deferred handles: {@link
+ * #awaitAll} prepares every pending deferred handle up front, executes the 
prepared calls as a
+ * batch, and only then collects the outcomes — so the requests are issued 
together instead of one
+ * at a time as each wait starts. Already resolved handles simply contribute 
their value.
+ */
+final class SubagentFutureGroup extends SubagentFutures {
+
+    private final List<SubagentFuture> futures;
+
+    SubagentFutureGroup(SubagentFuture first, SubagentFuture[] others) {
+        this(withFirst(first, others));
+    }
+
+    private static List<SubagentFuture> withFirst(SubagentFuture first, 
SubagentFuture[] others) {
+        List<SubagentFuture> all = new ArrayList<>(1 + others.length);
+        all.add(first);
+        all.addAll(Arrays.asList(others));
+        return all;
+    }
+
+    private SubagentFutureGroup(List<SubagentFuture> futures) {
+        this.futures = futures;
+    }
+
+    @Override
+    public boolean isDone() {
+        for (SubagentFuture future : futures) {
+            if (!future.isDone()) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    @Override
+    public List<Result> awaitAll() throws Exception {
+        // Prepare every pending deferred handle up front, so the whole batch 
is ready before any
+        // execution starts.
+        for (SubagentFuture future : futures) {
+            if (future instanceof DeferredSubagentFuture && !future.isDone()) {
+                ((DeferredSubagentFuture) future).prepare();
+            }
+        }
+        // TODO: execute the prepared calls as one batch once durable 
execution supports batched

Review Comment:
   Three different descriptions of the same behavior. This TODO says serial, 
the class javadoc at 30-33 says the calls go out together as a batch, and the 
public `SubagentFutures.awaitAll` javadoc says each handle resolves when its 
own wait starts (`SubagentFutures.java:44`). Callers read that last one.
   
   On the batching itself, #926 adds `reservePendingBatch` on both 
`RunnerContextImpl` and the Python bridge, which looks like the piece this TODO 
is waiting for. It's still open so nothing can lean on it yet, but have you 
tried the two together? Curious whether preparing everything up front and 
handing over one list fits, or whether the deferred mode needs something #926 
doesn't expose.



##########
python/flink_agents/runtime/async_subagent.py:
##########
@@ -0,0 +1,396 @@
+################################################################################
+#  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.
+################################################################################
+"""The async-job execution mode running in durable pub/sub mode."""
+
+import time
+from abc import ABC, abstractmethod
+from concurrent.futures import CancelledError
+from enum import Enum
+from typing import Any
+
+from flink_agents.api.runner_context import RunnerContext
+from flink_agents.api.subagent import (
+    Result,
+    SubagentFuture,
+    SubagentFutures,
+)
+from flink_agents.runtime.base_subagent import BaseSubagentSetup
+from flink_agents.runtime.subagent_handles import SubagentFutureGroup
+
+
+class RunStatus:
+    """State snapshot of a remote run reported by the ``call_query_status``
+    probe.
+
+    A state other than ``NOT_STARTED`` means the submission landed on the
+    service, which is the sole basis for ``reconcile_submit_request``
+    deciding between re-posting and polling. The snapshot never carries the
+    result payload.
+    """
+
+    class State(Enum):
+        """Lifecycle of the remote run."""
+
+        NOT_STARTED = "not_started"
+        RUNNING = "running"
+        COMPLETED = "completed"
+        FAILED = "failed"
+
+    def __init__(self, state: "RunStatus.State", error: str | None = None) -> 
None:
+        """Initialize with the lifecycle state and the optional error."""
+        self._state = state
+        self._error = error
+
+    @staticmethod
+    def not_started() -> "RunStatus":
+        """The service has no record of the run: the POST never landed."""
+        return RunStatus(RunStatus.State.NOT_STARTED)
+
+    @staticmethod
+    def running() -> "RunStatus":
+        """The run is in progress."""
+        return RunStatus(RunStatus.State.RUNNING)
+
+    @staticmethod
+    def completed() -> "RunStatus":
+        """The run finished successfully."""
+        return RunStatus(RunStatus.State.COMPLETED)
+
+    @staticmethod
+    def failed(error: str) -> "RunStatus":
+        """The run failed, carrying the error message."""
+        return RunStatus(RunStatus.State.FAILED, error)
+
+    @property
+    def state(self) -> "RunStatus.State":
+        """The lifecycle state of the remote run."""
+        return self._state
+
+    @property
+    def error(self) -> str | None:
+        """The error message of a failed run; None otherwise."""
+        return self._error
+
+
+class AsyncSubagentFuture(SubagentFuture):
+    """The sub side of an async-job invocation.
+
+    The run was already started by the durable POST of ``submit``, so the
+    handle only subscribes to it: :meth:`done` probes the status directly
+    (not durable); awaiting the handle waits through the durable await
+    composition; :meth:`cancel` propagates the cancellation through the
+    setup's hook, and a cancelled resolve raises ``CancelledError``.
+    """
+
+    def __init__(
+        self,
+        setup: "BaseAsyncSubagentSetup",
+        ctx: RunnerContext,
+        session_id: str,
+        call_id: str,
+    ) -> None:
+        """Initialize with the owning setup, the context, and the identity."""
+        super().__init__(session_id, call_id)
+        self._setup = setup
+        self._ctx = ctx
+        self._consumed = False
+        self._cancelled = False
+        self._value: Result | None = None
+
+    def done(self) -> bool:
+        """Probe the remote status directly; not durable.
+
+        A failover replay may probe a different number of times than the
+        original execution.
+        """
+        if self._consumed or self._cancelled:
+            return True
+        try:
+            probe = self._setup.query_status(self.session_id, self.call_id)
+        except Exception:
+            return False
+        return probe.state in (
+            RunStatus.State.COMPLETED,
+            RunStatus.State.FAILED,
+        )
+
+    def __await__(self) -> Any:
+        """Wait for the run through the durable await composition, releasing
+        the mailbox while waiting.
+
+        A cancelled handle raises :class:`CancelledError`.
+        """
+        if self._cancelled:
+            msg = f"Sub-agent call cancelled: {self.identity}"
+            raise CancelledError(msg)
+        if not self._consumed:
+            self._value = yield from self._ctx.durable_execute_async(
+                self._setup._await_until_terminal,
+                self.session_id,
+                self.call_id,
+                durable_id=f"{self.identity}#await",
+            ).__await__()
+            self._consumed = True
+        return self._value
+
+    def cancel(self) -> None:
+        """Propagate the cancellation through the setup's hook; not durable.
+
+        A failover replay (which creates a fresh handle) may propagate it
+        again; remote cancellations must be idempotent. A repeated cancel on
+        the same handle and a cancel after the resolve are local no-ops. A
+        cancelled resolve raises :class:`CancelledError`; a hook failure
+        propagates and fails the action.
+        """
+        if self._consumed or self._cancelled:
+            return
+        self._setup.cancel_request(self._ctx, self.session_id, self.call_id)
+        self._cancelled = True
+
+    def combine(self, *others: SubagentFuture) -> SubagentFutures:
+        """Group this handle with others for a batched resolve."""
+        return SubagentFutureGroup((self, *others))
+
+
+class BaseAsyncSubagentSetup(BaseSubagentSetup, ABC):
+    """Runtime base for sub-agents whose protocol is an asynchronous job,
+    run in pub/sub mode.
+
+    ``submit`` (the pub) starts the run remotely through one durable POST and
+    immediately returns a handle carrying the ``(session_id, call_id)``
+    identity; ``done``, the resolve and ``cancel`` on the handle (the sub)
+    query or steer that run. The shape matches LangGraph runs, OpenAI
+    Assistants runs, and A2A long-running tasks.
+
+    Integrations only provide the transport primitives they already
+    understand, with no durable concepts involved:
+
+    * :meth:`call_submit_request` — start the run remotely; a raised
+      exception fails the action;
+    * :meth:`call_query_status` — a read-only probe of the run's current
+      state;
+    * :meth:`call_fetch_result` — fetch the result of a run that reached a
+      terminal state;
+    * :meth:`call_cancel_request` — optional hook propagating a cancellation
+      to the remote run.
+
+    Persistence conventions:
+
+    * the submit POST — durable, id ``session_id#call_id``, the only
+      operation wired to a reconciler (:meth:`reconcile_submit_request`), so
+      the remote run is started at most once even across a crash between the
+      POST landing and its completion being persisted;
+    * :meth:`query_status` — not durable: a direct read-only probe. The state
+      advances monotonically toward a terminal state, so a replay observing a
+      fresher state is harmless;
+    * the fetch — durable, id ``session_id#call_id#fetch``: the result
+      enters the caller's data flow and must replay deterministically. No
+      reconciler; recovery re-executes the fetch, which is an idempotent
+      read;
+    * the await composition of the resolve — durable, id
+      ``session_id#call_id#await``: poll the status until a terminal
+      state, then fetch;
+    * :meth:`cancel_request` — not durable: a direct, synchronous
+      propagation. Remote cancellations are expected to be idempotent, so a
+      replay propagating the cancellation again is harmless.
+
+    The fetch and await ids are fixed per identity: both compositions are
+    built from idempotent reads, so a recovery re-executing them converges
+    to the same outcome as the original run.
+
+    Cancellation contract (dev-facing): cancel decisions typically depend on
+    nondeterministic inputs such as processing time, so a failover replay
+    does not promise control flow equivalent to the original execution. The
+    only at-most-once guarantee is the POST, enforced by the reconciler;
+    cancellation propagation is best-effort and idempotent. The hook returns
+    nothing: a cancelled resolve always raises ``CancelledError``, and a
+    hook failure propagates from :meth:`cancel` and fails the action.
+
+    Known limitations: a remote session or run record expiring after a
+    failover may change the non-durable status probe and prevent a replay
+    from reproducing the original fetch path (persisted fetch records still
+    short-circuit); a fetch in flight when the process crashed cannot be
+    recovered from a consume-once remote (the remote protocol must guarantee
+    idempotent reads); and any cancellation may discard a fetch that had
+    actually succeeded — cancel is the authoritative control-flow decision.
+    """
+
+    #: Delay between status probes while waiting for the run to reach a
+    #: terminal state; the Python parity of Java's statusPollIntervalMillis.
+    status_poll_interval_seconds: float = 0.01
+
+    def submit_with_identity(
+        self,
+        ctx: RunnerContext,
+        prompt: Any,
+        session_id: str,
+        call_id: str,
+    ) -> SubagentFuture:
+        """Start the remote run through the durable POST and return its
+        handle.
+
+        The POST runs on the mailbox thread and lands before the handle is
+        returned; a POST failure raises and fails the action.
+        """
+        self.submit_request(ctx, session_id, call_id, prompt)
+        return AsyncSubagentFuture(self, ctx, session_id, call_id)
+
+    # 
--------------------------------------------------------------------------------
+    # Framework wrappers: defaults composing the primitives, overridable
+    # 
--------------------------------------------------------------------------------
+
+    def submit_request(
+        self,
+        ctx: RunnerContext,
+        session_id: str,
+        call_id: str,
+        prompt: Any,
+    ) -> None:
+        """Run the durable POST of one invocation; the only wrapper wired to
+        a reconciler.
+
+        Recovery never assumes the POST was lost: the reconciler probes
+        first and resends only a missing run, so a crash after the POST
+        landed never duplicates the prompt.
+        """
+        ctx.durable_execute(

Review Comment:
   This one is synchronous. `durable_execute` is documented as blocking the 
operator until it finishes (`flink_runner_context.py:656`), so the whole remote 
POST sits on the mailbox thread and nothing else moves while it's out.
   
   Java does it async (`BaseAsyncSubagentSetup.java:113`), and the await path 
in this same file already uses `durable_execute_async` at line 142.
   
   Is the sync call intentional here, or should this match the await path?



##########
api/src/main/java/org/apache/flink/agents/api/subagent/SubagentFuture.java:
##########
@@ -0,0 +1,75 @@
+/*
+ * 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.api.subagent;
+
+/**
+ * Driving handle for one sub-agent invocation, carrying the {@code 
(sessionId, callId)} identity
+ * that keys the invocation's durable state.
+ *
+ * <p>The handle is a driver of the invocation's lifecycle, not a passive 
view: resolving it issues
+ * the request when it has not been issued yet, and its wait releases the 
mailbox so other work can
+ * proceed in between. Its heap state does not survive a failover; the 
identity is the only basis
+ * for rebuilding it through replay.
+ *
+ * <p>Returned by {@link Subagent#submit}. The invocation is always deferred: 
the request is issued

Review Comment:
   This says the request always goes out when the handle resolves, but the 
async mode POSTs during `submit` (`BaseAsyncSubagentSetup.java:113`), which its 
own javadoc calls the pub side.
   
   It shows up in `cancel`: cancelling a deferred handle sends nothing, 
cancelling an async one fires a remote cancel. Someone holding a 
`SubagentFuture` can't tell which they have from this text.
   
   Could this be scoped to the deferred mode?



##########
api/src/main/java/org/apache/flink/agents/api/subagent/Subagent.java:
##########
@@ -0,0 +1,44 @@
+/*
+ * 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.api.subagent;
+
+import org.apache.flink.agents.api.context.RunnerContext;
+
+/**
+ * Caller-facing interface for all sub-agents (external and internal).
+ *
+ * <p>An invocation is identified by a {@code (sessionId, callId)} pair; the 
session groups a
+ * conversation across invocations. Callers do not manage ids: the short forms 
below leave the
+ * missing ids to the implementation, which assigns them (runtime setups 
typically through a
+ * deterministic id allocator, stable across failover replays) or rejects the 
call.
+ *
+ * <p>The full form taking the complete {@code (sessionId, callId)} identity 
is the
+ * implementation-side contract, declared by {@link SubagentSetup}; resolving 
a returned handle is
+ * {@code await}.
+ */
+public interface Subagent {
+
+    /**
+     * Issues an invocation under the given {@code sessionId}; the 
implementation picks the call id.
+     */
+    SubagentFuture submit(RunnerContext ctx, Object prompt, String sessionId) 
throws Exception;

Review Comment:
   nit: the PR description still describes the old surface. `asAsyncCallable`, 
`callAsync` and `executeAllAsync` have no occurrences left in the branch, and 
`call()` is gone from this interface. Worth refreshing it so the body matches 
`cc114aef`?



##########
runtime/src/main/java/org/apache/flink/agents/runtime/subagent/AsyncSubagentFuture.java:
##########
@@ -0,0 +1,131 @@
+/*
+ * 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.subagent;
+
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.subagent.Result;
+import org.apache.flink.agents.api.subagent.SubagentFuture;
+import org.apache.flink.agents.api.subagent.SubagentFutures;
+import 
org.apache.flink.agents.runtime.subagent.BaseAsyncSubagentSetup.RunStatus;
+
+import javax.annotation.Nullable;
+
+import java.util.concurrent.CancellationException;
+
+/**
+ * The sub side of an async-job invocation: the run was already started by the 
durable POST of
+ * {@code submit}, so the handle only subscribes to it. {@link #isDone()} 
probes the status directly
+ * (not durable); {@link #await()} waits through the durable await 
composition; {@link #cancel()}
+ * propagates the cancellation through the setup's hook, and a cancelled 
{@code await} fails as a
+ * {@link CancellationException}.
+ *
+ * <p>The handle records itself in the owning base's per-task registry, so 
dropping it without
+ * collecting the outcome fails the action instead of silently losing the 
result.
+ */
+final class AsyncSubagentFuture extends SubagentFuture {
+
+    private final BaseAsyncSubagentSetup setup;
+    private final RunnerContext ctx;
+    @Nullable private final PendingSubagentCallRegistry registry;
+
+    private boolean consumed;
+    private boolean cancelled;
+    @Nullable private Result value;
+
+    AsyncSubagentFuture(
+            BaseAsyncSubagentSetup setup,
+            RunnerContext ctx,
+            String sessionId,
+            String callId,
+            @Nullable PendingSubagentCallRegistry registry) {
+        super(sessionId, callId);
+        this.setup = setup;
+        this.ctx = ctx;
+        this.registry = registry;
+        if (registry != null) {
+            registry.trackPendingSubagentCall(identity());
+        }
+    }
+
+    /**
+     * Probes the remote status directly; not durable, so a failover replay 
may probe a different
+     * number of times than the original execution.
+     */
+    @Override
+    public boolean isDone() {
+        if (consumed || cancelled) {
+            return true;
+        }
+        try {
+            RunStatus probe = setup.queryStatus(getSessionId(), getCallId());
+            return probe.getState() == RunStatus.State.COMPLETED
+                    || probe.getState() == RunStatus.State.FAILED;
+        } catch (Exception e) {

Review Comment:
   A failing probe turns into `false` with nothing logged. If `queryStatus` 
keeps failing, say expired credentials or a dead endpoint, `isDone()` reports 
"not done" forever and there's no clue why. `Result.error` does log on the same 
kind of path (`Result.java:79`). Worth a debug log here?



##########
runtime/src/main/java/org/apache/flink/agents/runtime/subagent/BaseDeferredSubagentSetup.java:
##########
@@ -0,0 +1,70 @@
+/*
+ * 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.subagent;
+
+import org.apache.flink.agents.api.context.DurableCallable;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.subagent.Result;
+import org.apache.flink.agents.api.subagent.SubagentFuture;
+
+/**
+ * Framework-level deferred execution mode for sub-agent setups: invocations 
run through one async
+ * callable, issued lazily through a deferred handle. The mode is not tied to 
external services; an
+ * internal sub-agent drives its child plan through the same shape.
+ *
+ * <p>{@link #submit} always returns a deferred handle built on {@link 
DeferredSubagentFuture}: the
+ * request is prepared when the handle is resolved. Implementations only 
provide the terminal {@link
+ * #prepare}, which supplies the {@link DurableCallable} running the 
invocation; the handle feeds it
+ * to durable execution itself.
+ *
+ * <p>The short {@code submit} forms are inherited from {@link 
BaseSubagentSetup}: the base assigns
+ * the missing ids deterministically from the executing task before the 
deferred handle is created.
+ * The dropped-handle safety net is built in: the handle records itself in the 
base's per-task
+ * registry, and a handle left unresolved when the action finishes fails the 
action.
+ */
+public abstract class BaseDeferredSubagentSetup extends BaseSubagentSetup {
+
+    @Override
+    public SubagentFuture submit(RunnerContext ctx, Object prompt, String 
sessionId, String callId)
+            throws Exception {
+        return new DeferredSubagentFuture(
+                sessionId,
+                callId,
+                ctx,
+                currentTaskRegistry(),
+                () -> prepare(ctx, prompt, sessionId, callId));
+    }
+
+    /**
+     * Prepares one invocation and returns the {@link DurableCallable} running 
it: the stable
+     * durable id, the callable running the off-mailbox part, and the optional 
recovery reconciler.
+     * Both ids are already assigned; the durable id MUST be derived solely 
from the {@code
+     * (sessionId, callId)} pair so it is reproducible after failover.
+     *
+     * <p>Called exactly once per invocation, when the deferred handle is 
first resolved, on the
+     * mailbox thread. Implementations may therefore perform the 
mailbox-confined part of issuing
+     * the request here (an internal sub-agent sends its call event); the 
returned callable's {@link
+     * DurableCallable#call()} carries only the part that runs off the mailbox 
thread.
+     *
+     * <p>Implementations that recover an in-flight invocation after failover 
supply the reconciler

Review Comment:
   nit: following up on the reconciler question from last round. The async base 
now wires one by default, and leaving it to the implementor here seems right, 
since the deferred mode isn't tied to external services.
   
   Would it be worth spelling out what skipping it costs? Something like: 
without a reconciler, a crash between the call landing and its result being 
persisted re-invokes on replay. Then it reads as a choice rather than a default.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to