diff --git a/workspace/a2a_tools_delegation.py b/workspace/a2a_tools_delegation.py index 79f42fd1..4fcc2ee8 100644 --- a/workspace/a2a_tools_delegation.py +++ b/workspace/a2a_tools_delegation.py @@ -204,6 +204,20 @@ async def tool_delegate_task( if not workspace_id or not task: return "Error: workspace_id and task are required" + # Self-delegation guard: delegating to your own workspace ID deadlocks — + # the sending turn holds _run_lock while the receive handler waits for the + # same lock, the request 30s-times-out, and the whole cycle is wasted. + # Reject immediately with an actionable message. (effective_src mirrors the + # `src or WORKSPACE_ID` resolution used below for routing.) + effective_src = source_workspace_id or _peer_to_source.get(workspace_id) or WORKSPACE_ID + if workspace_id and workspace_id == effective_src: + return ( + "Error: cannot delegate_task to your own workspace — self-delegation " + "deadlocks _run_lock (your sending turn holds it, the receive handler " + "waits for it, the request times out). There is no peer who is also you: " + "just do the work yourself, or call commit_memory / send_message_to_user directly." + ) + # Auto-route: if source not specified, look up which registered # workspace last saw this peer (populated by tool_list_peers). Falls # back to the legacy WORKSPACE_ID for single-workspace operators. @@ -323,6 +337,16 @@ async def tool_delegate_task_async( src = source_workspace_id or _peer_to_source.get(workspace_id) or WORKSPACE_ID + # Self-delegation guard: even on the async path, queuing a task to your own + # workspace just makes you re-process your own dispatch — never useful, and + # on the sync path it deadlocks (see tool_delegate_task). Reject early. + if workspace_id and workspace_id == src: + return ( + "Error: cannot delegate_task_async to your own workspace — there is no " + "peer who is also you. Do the work yourself, or call commit_memory / " + "send_message_to_user directly." + ) + # Idempotency key: SHA-256 of (source, target, task) so that a # restarted agent firing the same delegation gets the same key and # the platform returns the existing delegation_id instead of diff --git a/workspace/tests/test_a2a_tools_delegation.py b/workspace/tests/test_a2a_tools_delegation.py index 010f4e45..84c2fe0d 100644 --- a/workspace/tests/test_a2a_tools_delegation.py +++ b/workspace/tests/test_a2a_tools_delegation.py @@ -127,3 +127,51 @@ class TestPollBudgetEnvOverride: # numeric and >= the documented floor (180s healthsweep budget). assert isinstance(a2a_tools_delegation._SYNC_POLL_BUDGET_S, float) assert a2a_tools_delegation._SYNC_POLL_BUDGET_S >= 180.0 + + +# ============== Self-delegation guard ============== + +class TestSelfDelegationGuard: + """delegate_task / delegate_task_async to your own workspace ID must be + rejected immediately (it deadlocks _run_lock on the sync path — the + sending turn holds the lock, the receive handler waits for it, the + request 30s-times-out). A genuinely different target must NOT be + short-circuited by the guard.""" + + def _fresh(self, monkeypatch, own_id): + import a2a_tools_delegation as d + monkeypatch.setattr(d, "WORKSPACE_ID", own_id) + monkeypatch.setattr(d, "_peer_to_source", {}, raising=False) + return d + + def test_delegate_task_rejects_self(self, monkeypatch): + import asyncio + d = self._fresh(monkeypatch, "ws-self-abc") + out = asyncio.run(d.tool_delegate_task("ws-self-abc", "do a thing")) + assert "your own workspace" in out.lower() + + def test_delegate_task_rejects_self_via_explicit_source(self, monkeypatch): + import asyncio + d = self._fresh(monkeypatch, "ws-other-default") + out = asyncio.run( + d.tool_delegate_task("ws-X", "do a thing", source_workspace_id="ws-X") + ) + assert "your own workspace" in out.lower() + + def test_delegate_task_async_rejects_self(self, monkeypatch): + import asyncio + d = self._fresh(monkeypatch, "ws-self-abc") + out = asyncio.run(d.tool_delegate_task_async("ws-self-abc", "do a thing")) + assert "your own workspace" in out.lower() + + def test_delegate_task_allows_different_target(self, monkeypatch): + """Guard passes through for a real peer — it reaches discover_peer + (stubbed to 'not found' here) rather than returning the self message.""" + import asyncio + d = self._fresh(monkeypatch, "ws-self-abc") + async def _no_peer(*_a, **_kw): + return None + monkeypatch.setattr(d, "discover_peer", _no_peer) + out = asyncio.run(d.tool_delegate_task("ws-OTHER-xyz", "do a thing")) + assert "your own workspace" not in out.lower() + assert "not found" in out.lower()