Skip to content

Fix agent circular-dependency deadlocks in agent-to-agent messaging and task delegatio - #581

Closed
QuanCheng-QC wants to merge 5 commits into
developfrom
bugfix/agent-circular-wait-deadlock
Closed

Fix agent circular-dependency deadlocks in agent-to-agent messaging and task delegatio#581
QuanCheng-QC wants to merge 5 commits into
developfrom
bugfix/agent-circular-wait-deadlock

Conversation

@QuanCheng-QC

Copy link
Copy Markdown
Collaborator

Problem

In the mesh (logical full-mesh over a central network server), two agents waiting on each other's results deadlock. Three distinct failure modes existed:

  1. Mutual blocking waitssend_and_wait inside react() stalls the agent's serial event loop; if the peer blocks the same way, both hang until timeout. Worse, waiters matched replies by source_id only, so a counter-request could be silently mistaken for a reply, and a fast reply could be lost entirely because the waiter was registered after sending (lost-wakeup race).
  2. Circular task delegation — task delegation had zero cycle protection: A→B→A (or longer cycles) created tasks forever, each burning its own 300s timeout while the loop kept spawning new work.
  3. Blocked receive loop — blocking handlers registered directly on the client run inside the connector's receive task and can freeze heartbeats.

Fix

Sync-wait governance (P0)

  • Reply waiters are registered before sending (AgentClient.expect_event / EventWaiter), closing the lost-wakeup race.
  • Replies are correlated precisely via the existing Event.response_to field; the messaging mod propagates response_to/requires_response across the direct-message relay and exposes message_id so receivers can reply with correlation (WorkerAgent.reply_direct). Counter-requests are no longer mistaken for replies; uncorrelated legacy replies still work via a fallback.
  • react() execution is tracked via a ContextVar bound to its owning asyncio task; blocking wait primitives called inside react() emit a DeprecationWarning (OPENAGENTS_STRICT_NO_BLOCKING_WAIT=1 upgrades to an error). Background tasks spawned inside react() and agent-to-mod RPCs are exempt.
  • Fixed dead code: AgentClient.wait_mod_message did not exist — channel wait_for_reply/wait_for_post/wait_for_reaction/post_and_wait and Workspace.channels(refresh=True) always raised AttributeError internally and silently returned None; their match conditions also targeted event shapes the messaging mod never emits. Both fixed; post_and_wait now correlates replies to the posted message id.

Delegation lineage (P1)

  • Clients may pass parent_task_id; the server derives root_task_id / delegation_chain / delegation_depth from the task store (clients cannot forge chains). Rejects self-delegation (agent-id normalization prevents agent: alias bypass), cycles (delegation_cycle_detected), depth over max_delegation_depth, and fan-out over max_active_delegations_per_agent — validation and task creation run under one lock so concurrent delegations cannot race past the fuse. Covers both task.delegate and capability-routed paths; assignment notifications carry lineage so assignees can extend the chain.

Wait-graph deadlock detection (P1)

  • The event gateway hosts a WaitGraphMonitor: blocking primitives mark requests with blocking_wait/wait_timeout metadata (plain requires_response continuation requests never create edges), edges clear on matching response_to or expire at the waiter's own deadline. When an edge closes a cycle, every agent on it receives agent.wait.deadlock_detected (bypassing subscription filters) naming the exact request ids; send_and_wait fails fast with the cycle path instead of timing out blind. Cycle edges are removed on notification so stale edges cannot re-trigger. Endpoints are canonicalized; self-waits are reported as single-node cycles. This catches what lineage checks cannot see: mutual send_and_wait, independent tasks pointing at each other, and mixed-mechanism cycles.
  • The gRPC proto gained requires_response/response_to fields (backward-compatible tags 11/12, pb2 regenerated) so correlation survives the transport; stringified gRPC metadata is parsed leniently.

Hygiene (P2)

  • task.complete responses no longer report completed_at as None (stale snapshot); locally timed-out external tasks stop their background polling; gRPC SendEvent now has a 30s deadline; docs teach the continuation pattern and the no-blocking-wait-in-react rule.

Testing

64 new tests across tests/workspace/test_sync_wait_governance.py, tests/mods/test_task_delegation.py (TestDelegationLineage), tests/network/test_wait_graph.py, tests/grpc/test_grpc_send_event_deadline.py, tests/grpc/test_grpc_event_roundtrip.py — covering mutual-wait warning/strict modes, lost-wakeup, reply misclassification, concurrent waiters, deadlock fast-fail, self/parent-child/three-hop delegation cycles, depth and fan-out fuses (including concurrency), gRPC field roundtrip, and all hygiene regressions. All affected suites pass (96 passed); the remaining errors in tests/grpc//integration suites are a pre-existing missing examples/workspace_test.yaml fixture path that fails identically on develop.

Addressed two rounds of external review (6 + 2 blockers, all resolved in the last two commits).

Two agents that block on each other inside react() stall until their
timeouts, and replies could be lost or misclassified. This commit
governs the blocking wait primitives

- register reply waiters before sending in send_and_wait, post_and_wait
  and Workspace.channels, closing the lost-wakeup race where a fast
  reply arrived before the waiter existed
- correlate replies precisely via the existing Event.response_to field.
  The messaging mod now propagates response_to and requires_response
  across the direct-message relay and exposes message_id so receivers
  can reply with correlation. A counter-request from the peer is no
  longer mistaken for a reply
- add AgentClient.expect_event and EventWaiter as the
  register-then-send primitive, and implement the previously missing
  AgentClient.wait_mod_message that channel wait helpers were calling
  (they always raised AttributeError and silently returned None)
- match channel wait conditions against the notification events the
  messaging mod actually emits (thread.reply.notification,
  thread.channel_message.notification, thread.reaction.notification)
- mark react() execution via a ContextVar in the runner and warn with
  DeprecationWarning when a blocking wait primitive is called inside
  react(). Setting OPENAGENTS_STRICT_NO_BLOCKING_WAIT=1 upgrades the
  warning to BlockingWaitInReactError. Agent-to-mod RPCs are unaffected
- add WorkerAgent.reply_direct for correlated replies to direct messages

Covered by tests/workspace/test_sync_wait_governance.py
Task delegation had no defense against circular delegation. A->B->A (or
any longer cycle) created tasks forever, each burning its own timeout
while the loop kept spawning new work.

Delegation lineage (task_delegation mod)
- clients may pass parent_task_id when delegating as part of handling a
  task. The server derives root_task_id, delegation_chain and
  delegation_depth from the parent task in the task store, so a client
  cannot forge or truncate the chain
- rejects self-delegation (agent ids normalized so agent prefix aliases
  cannot bypass the check), delegation cycles, chains beyond
  max_delegation_depth, and delegators over the
  max_active_delegations_per_agent fuse, since cycle checks alone cannot
  stop non-cyclic exponential fan-out
- both creation paths are covered, direct task.delegate and
  capability-routed tasks
- assignment notifications carry the lineage so assignees can extend the
  chain with parent_task_id on their own delegations

Wait-graph deadlock detection (event gateway)
- every event flows through the central gateway, so a WaitGraphMonitor
  builds a wait-for graph there from requires_response requests and
  clears edges on matching response_to replies, with a TTL for
  abandoned waits
- when an edge closes a cycle, every agent on it receives an
  agent.wait.deadlock_detected event naming the cycle. send_and_wait
  recognizes reports about its own request and fails fast with a clear
  log instead of blocking until timeout
- this covers what lineage checks cannot see, mutual send_and_wait,
  independent tasks pointing at each other, and mixed-mechanism cycles

Covered by tests/mods/test_task_delegation.py (TestDelegationLineage),
tests/network/test_wait_graph.py and
tests/workspace/test_sync_wait_governance.py
- task.complete responses reported completed_at as None because the
  delegation snapshot was taken before the completion timestamp was
  written; re-extract after the update
- a locally timed-out external task kept its background polling loop
  alive until process shutdown; stop polling when the timeout marks the
  task failed
- gRPC SendEvent had no deadline, so a stalled server blocked the caller
  forever; use a 30s deadline in line with the client wait defaults
- document the no-blocking-wait-in-react rule and the continuation
  pattern with reply_direct in the workspace interface guide
Four merge blockers from review plus two follow-up suggestions

- only explicit blocking waits become wait-graph edges. send_and_wait
  now marks its request with blocking_wait and wait_timeout metadata;
  requires_response alone no longer creates an edge, so continuation and
  fire-and-forget requests cannot form false cycles that abort real
  waiters. Edges expire at the waiter's own deadline instead of a fixed
  TTL
- deadlock notifications bypass event subscription filtering in the
  gateway; an agent subscribed only to thread.* still receives
  agent.wait.deadlock_detected instead of silently degrading to a blind
  timeout
- wait-graph endpoints are canonicalized (agent prefix stripped) so
  alias forms join one graph, and a self-wait is reported as an
  immediate single-node cycle instead of being ignored
- delegation lineage validation and task creation now run under one
  lock, so concurrent delegations cannot both pass the active-delegation
  fuse before either task is stored
- DFS returns the exact edges forming the cycle and only those request
  ids are reported, leaving unrelated concurrent waits between the same
  agents untouched
- react scope is bound to its owning asyncio task; background tasks
  spawned inside react() inherit the ContextVar but are no longer
  treated as inside react, so post-handler continuations cannot trigger
  false warnings or strict-mode errors

Adds regression tests for all of the above and for the earlier hygiene
fixes (completed_at now populated, external polling stopped on local
timeout, gRPC SendEvent deadline). Rebased onto latest develop.
Second-round review blockers

- the gRPC Event proto had no requires_response / response_to fields, so
  both were dropped on ingress. Precise reply matching fell back to the
  legacy source-only heuristic and wait-graph edges were never cleared
  by responses from gRPC agents. Added the fields to the proto
  (backward-compatible tag numbers 11 and 12), regenerated the pb2
  modules, set them in the client-side conversion and read them
  defensively in a new internal_event_from_grpc helper on the server
- gRPC metadata is transported as map<string,string>, which stringified
  wait markers. The wait-graph now parses metadata leniently, the
  string False no longer counts as a blocking wait and numeric strings
  keep their value as the edge deadline
- edges forming a detected cycle are removed before the notification is
  delivered. The notified waiters return immediately, so keeping their
  edges alive until the original timeout made later unrelated waits
  between the same agents look like fresh deadlocks
- send_and_wait now applies its internal blocking_wait / wait_timeout
  metadata after caller-supplied metadata, so a caller cannot disable
  detection or forge the deadline

Adds an Event to gRPC to Event roundtrip test for the correlation
fields, plus wait-graph tests for stringified metadata and for
post-notification edge cleanup.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant