From d70b474a14777e54094a6af27283c80e1813f46a Mon Sep 17 00:00:00 2001 From: nankingjing <1079826437@qq.com> Date: Fri, 3 Jul 2026 13:00:10 +0800 Subject: [PATCH] fix(delegate): pin async completion to spawning parent session (#57498) Background delegate_task completions only carried session_key. When multiple active sessions shared a routing peer, get_or_create_session could recover the latest ended_at IS NULL row and inject the subagent result into the wrong session. Capture parent_agent.session_id at dispatch time, include it on async-delegation completion events, and pin gateway routing via switch_session when the synthetic completion message is handled. Fixes #57498 --- gateway/run.py | 20 ++++++++++++++++++++ tests/tools/test_async_delegation.py | 2 ++ tools/async_delegation.py | 11 +++++++++++ tools/delegate_tool.py | 2 ++ 4 files changed, 35 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index d3992a760e824..a5146c860a80e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10325,6 +10325,21 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g session_entry = self.session_store.get_or_create_session(source) session_key = session_entry.session_key + pinned_session_id = str( + (getattr(event, "metadata", None) or {}).get("gateway_session_id") or "" + ).strip() + if pinned_session_id and pinned_session_id != session_entry.session_id: + prior_session_id = session_entry.session_id + switched = self.session_store.switch_session(session_key, pinned_session_id) + if switched is not None: + session_entry = switched + logger.info( + "Pinned async-delegation completion to spawning session %s " + "(was %s) for routing key %s (#57498)", + pinned_session_id, + prior_session_id, + session_key, + ) self._cache_session_source(session_key, source) if await asyncio.to_thread(self._is_telegram_topic_lane, source): try: @@ -14719,12 +14734,17 @@ async def _inject_watch_notification(self, synth_text: str, evt: dict) -> None: if not adapter: return try: + metadata = {} + parent_session_id = str(evt.get("parent_session_id") or "").strip() + if parent_session_id: + metadata["gateway_session_id"] = parent_session_id synth_event = MessageEvent( text=synth_text, message_type=MessageType.TEXT, source=source, internal=True, message_id=str(evt.get("message_id") or "").strip() or None, + metadata=metadata, ) logger.info( "Watch pattern notification — injecting for %s chat=%s thread=%s", diff --git a/tests/tools/test_async_delegation.py b/tests/tools/test_async_delegation.py index 0cbd9313cfb47..41681a9a96fab 100644 --- a/tests/tools/test_async_delegation.py +++ b/tests/tools/test_async_delegation.py @@ -99,6 +99,7 @@ def runner(): res = ad.dispatch_async_delegation( goal="compute X", context="some context", toolsets=["web", "file"], role="leaf", model="test-model", session_key="agent:main:cli:dm:local", + parent_session_id="20260703_parent_sid", runner=runner, max_async_children=3, ) assert res["status"] == "dispatched" @@ -108,6 +109,7 @@ def runner(): assert evt["type"] == "async_delegation" assert evt["summary"] == "the result" assert evt["session_key"] == "agent:main:cli:dm:local" + assert evt["parent_session_id"] == "20260703_parent_sid" assert evt["delegation_id"] == res["delegation_id"] diff --git a/tools/async_delegation.py b/tools/async_delegation.py index f28156e2f57a2..7263bfd000ca9 100644 --- a/tools/async_delegation.py +++ b/tools/async_delegation.py @@ -129,6 +129,7 @@ def dispatch_async_delegation( role: str, model: Optional[str], session_key: str, + parent_session_id: Optional[str] = None, runner: Callable[[], Dict[str, Any]], interrupt_fn: Optional[Callable[[], None]] = None, max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN, @@ -145,6 +146,11 @@ def dispatch_async_delegation( captured on the parent thread BEFORE dispatch, because the daemon worker thread won't carry the contextvar. Used to route the completion back to the originating session. + parent_session_id + The durable ``state.db`` session id of the parent agent that spawned + the delegation. Carried on the completion event so the gateway can + pin routing to the spawning session instead of recovering the latest + ``ended_at IS NULL`` row for the peer tuple (#57498). runner Zero-arg callable that builds + runs the child and returns the same result dict ``_run_single_child`` produces. Runs on the worker thread. @@ -172,6 +178,7 @@ def dispatch_async_delegation( "role": role, "model": model, "session_key": session_key, + "parent_session_id": parent_session_id, "status": "running", "dispatched_at": dispatched_at, "completed_at": None, @@ -282,6 +289,7 @@ def _push_completion_event( # session_key routes the completion back to the originating gateway # session; empty string => CLI (single-session) path. "session_key": record.get("session_key", ""), + "parent_session_id": record.get("parent_session_id"), "goal": record.get("goal", ""), "context": record.get("context"), "toolsets": record.get("toolsets"), @@ -316,6 +324,7 @@ def dispatch_async_delegation_batch( role: str, model: Optional[str], session_key: str, + parent_session_id: Optional[str] = None, runner: Callable[[], Dict[str, Any]], interrupt_fn: Optional[Callable[[], None]] = None, max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN, @@ -356,6 +365,7 @@ def dispatch_async_delegation_batch( "role": role, "model": model, "session_key": session_key, + "parent_session_id": parent_session_id, "status": "running", "dispatched_at": dispatched_at, "completed_at": None, @@ -453,6 +463,7 @@ def _finalize_batch( "type": "async_delegation", "delegation_id": delegation_id, "session_key": event_record.get("session_key", ""), + "parent_session_id": event_record.get("parent_session_id"), "goal": event_record.get("goal", ""), "goals": event_record.get("goals"), "context": event_record.get("context"), diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index b3172e51acdc9..0325ac50ad1d8 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2807,6 +2807,7 @@ def _execute_and_aggregate() -> dict: return json.dumps(_sync_result, ensure_ascii=False) _session_key = get_current_session_key(default="") + _parent_session_id = getattr(parent_agent, "session_id", None) _child_agents = [c for (_, _, c) in children] # Detach every child from the parent's interrupt-propagation list — the @@ -2847,6 +2848,7 @@ def _batch_interrupt(): role=top_role, model=creds["model"], session_key=_session_key, + parent_session_id=_parent_session_id, runner=_batch_runner, interrupt_fn=_batch_interrupt, max_async_children=_get_max_async_children(),