Fix WhatsApp overlap by serial queue, typing, and outbound delivery.

Serialize per-session inbound turns and merge follow-ups after the active run; show composing while busy and enqueue final replies so long agent runs still reach the group.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-22 10:04:25 +08:00
parent f1ed86537c
commit 2446993b11
6 changed files with 801 additions and 79 deletions

View file

@ -0,0 +1,155 @@
"""Per-session serial queue: one active turn; overlaps enqueue and merge on drain."""
from __future__ import annotations
import threading
import uuid
from dataclasses import dataclass, field
from typing import Any
@dataclass
class ChannelTurnHandle:
session_id: str
run_id: str
@dataclass
class _SessionState:
active_run_id: str = ""
pending: list[dict[str, Any]] = field(default_factory=list)
def merge_channel_pending_jobs(jobs: list[dict[str, Any]]) -> dict[str, Any]:
"""Merge queued follow-ups into one job for the next agent turn."""
rows = [j for j in (jobs or []) if isinstance(j, dict)]
if not rows:
return {}
if len(rows) == 1:
return dict(rows[0])
lang = str(rows[-1].get("lang") or "").strip().lower()
if lang.startswith("zh"):
header = "处理上一问期间又收到多条跟进,请一并回答:"
else:
header = "Several follow-up questions arrived while the previous request was still running. Please answer them together:"
parts: list[str] = []
attachments: list[dict[str, Any]] = []
for idx, job in enumerate(rows, start=1):
text = str(job.get("user_text") or "").strip()
if text:
parts.append(f"{idx}) {text}")
for att in job.get("attachments") or []:
if isinstance(att, dict):
attachments.append(att)
merged = dict(rows[-1])
body = "\n\n".join(parts) if parts else ""
merged["user_text"] = f"{header}\n\n{body}".strip() if body else header
merged["attachments"] = attachments
merged["merged_count"] = len(rows)
return merged
class ChannelTurnGate:
"""Serialize channel agent turns per session_id.
- Idle ``try_begin``: start a turn immediately.
- Busy ``try_begin``: append job to pending and return None (caller returns quickly).
- ``end_and_take_merged``: clear active; if pending exists, merge all into one job and
start the next turn handle for the same HTTP request to continue.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._sessions: dict[str, _SessionState] = {}
def try_begin(self, session_id: str, job: dict[str, Any]) -> ChannelTurnHandle | None:
sid = str(session_id or "").strip()
if not sid:
raise ValueError("session_id_required")
payload = dict(job or {})
with self._lock:
st = self._sessions.get(sid)
if st is None:
st = _SessionState()
self._sessions[sid] = st
if st.active_run_id:
st.pending.append(payload)
return None
run_id = uuid.uuid4().hex
st.active_run_id = run_id
return ChannelTurnHandle(session_id=sid, run_id=run_id)
def is_current(self, handle: ChannelTurnHandle) -> bool:
sid = str(handle.session_id or "").strip()
rid = str(handle.run_id or "").strip()
if not sid or not rid:
return False
with self._lock:
st = self._sessions.get(sid)
return bool(st and st.active_run_id == rid)
def pending_count(self, session_id: str) -> int:
sid = str(session_id or "").strip()
with self._lock:
st = self._sessions.get(sid)
return len(st.pending) if st else 0
def end_and_take_merged(self, handle: ChannelTurnHandle) -> tuple[dict[str, Any] | None, ChannelTurnHandle | None]:
"""Finish current turn. If queue non-empty, merge and return (merged_job, next_handle)."""
sid = str(handle.session_id or "").strip()
rid = str(handle.run_id or "").strip()
if not sid or not rid:
return None, None
with self._lock:
st = self._sessions.get(sid)
if st is None or st.active_run_id != rid:
return None, None
pending = list(st.pending)
st.pending.clear()
if not pending:
st.active_run_id = ""
self._sessions.pop(sid, None)
return None, None
merged = merge_channel_pending_jobs(pending)
next_id = uuid.uuid4().hex
st.active_run_id = next_id
return merged, ChannelTurnHandle(session_id=sid, run_id=next_id)
def force_end(self, handle: ChannelTurnHandle) -> None:
"""Drop active marker if still ours (error paths). Pending jobs are kept."""
sid = str(handle.session_id or "").strip()
rid = str(handle.run_id or "").strip()
if not sid or not rid:
return
with self._lock:
st = self._sessions.get(sid)
if st is None or st.active_run_id != rid:
return
st.active_run_id = ""
if not st.pending:
self._sessions.pop(sid, None)
_GATE = ChannelTurnGate()
def get_channel_turn_gate() -> ChannelTurnGate:
return _GATE
def reset_channel_turn_gate_for_tests() -> ChannelTurnGate:
global _GATE
_GATE = ChannelTurnGate()
return _GATE
__all__ = [
"ChannelTurnGate",
"ChannelTurnHandle",
"get_channel_turn_gate",
"merge_channel_pending_jobs",
"reset_channel_turn_gate_for_tests",
]

View file

@ -889,6 +889,86 @@ def _should_suppress_channel_reply(*, channel: str, text: str) -> bool:
return False
def _env_flag_enabled(name: str, *, default: bool = True) -> bool:
import os
raw = str(os.getenv(name) or "").strip().lower()
if not raw:
return bool(default)
return raw not in {"0", "false", "no", "off"}
def _whatsapp_inbound_queue_delivery_enabled() -> bool:
return _env_flag_enabled("OCLAW_WHATSAPP_INBOUND_QUEUE_DELIVERY", default=True)
def _channel_turn_serial_queue_enabled() -> bool:
return _env_flag_enabled("OCLAW_CHANNEL_TURN_SERIAL_QUEUE", default=True)
def _enqueue_whatsapp_inbound_reply(
store: Any,
*,
inbound: Any,
account_id: str,
tenant_id: str,
reply_text: str,
reply_attachments: list[dict[str, Any]] | None,
reply_metadata: dict[str, Any] | None,
) -> str:
"""Persist final WhatsApp reply on the outbound queue (sidecar poller delivers)."""
from runtime.scheduler.whatsapp_mentions import encode_whatsapp_outbound_source
text = str(reply_text or "").strip()
reply: dict[str, Any] = {
"channel": "whatsapp",
"chat_id": str(getattr(inbound, "external_chat_id", "") or ""),
"text": text,
"attachments": list(reply_attachments or []),
"metadata": dict(reply_metadata or {}),
}
_maybe_expand_reply_attachments_for_channel(reply)
_maybe_add_media_path_for_wechat_reply(reply)
atts = [a for a in (reply.get("attachments") or []) if isinstance(a, dict)]
media_path = str(reply.get("media_path") or "").strip() or None
meta = dict(reply.get("metadata") or {}) if isinstance(reply.get("metadata"), dict) else {}
mention_jids = meta.get("mention_jids") if isinstance(meta.get("mention_jids"), list) else None
mention_names = meta.get("mention_names") if isinstance(meta.get("mention_names"), list) else None
quote_extra = {
k: meta[k]
for k in (
"quote_remote_jid",
"quote_stanza_id",
"quote_participant",
"quote_text",
"quote_push_name",
"reply_to_user_id",
"is_group",
)
if str(meta.get(k) or "").strip() or meta.get(k) is True
}
source = encode_whatsapp_outbound_source(
kind="inbound_reply",
mention_jids=mention_jids,
mention_names=mention_names,
mention_text_ready=bool(mention_jids),
attachments=atts or None,
media_path=media_path,
extra=quote_extra or None,
)
return str(
store.enqueue_channel_outbound_message(
channel="whatsapp",
chat_id=str(getattr(inbound, "external_chat_id", "") or ""),
text=text,
tenant_id=str(tenant_id or ""),
account_id=str(account_id or "").strip() or "wa-default",
source=source,
)
or ""
)
def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
from runtime.operations.mcp_env import apply_gateway_mcp_env_to_os
@ -951,6 +1031,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
preface = ""
channel_session_id = ""
channel_turn_uuid = ""
tenant_id = ""
if text.lower().startswith("bind "):
code = text.split(None, 1)[-1].strip()
info = store.consume_bind_code(
@ -1264,79 +1345,220 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
if not user_text and gw_attachments:
user_text = "用户发送了附件,请根据附件内容回复。"
if user_text or gw_attachments:
try:
from runtime.gateway import OclawGateway
from runtime.types import StandardMessage
from runtime.lang import resolve_runtime_lang
from runtime.gateway import OclawGateway
from runtime.types import StandardMessage
from runtime.lang import resolve_runtime_lang
from runtime.application.gateway.channel_turn_gate import get_channel_turn_gate
interaction_mode, selected_specialist, dispatch_lang = _resolve_channel_dispatch(
store, channel=inbound.channel, account=account
)
lang = (
dispatch_lang
if dispatch_lang in {"zh", "en"}
else resolve_runtime_lang(store=store, user_text=user_text)
)
gw = OclawGateway(store=store)
msg = StandardMessage(
session_id=str(session_id),
tenant_id=str(tenant_id or ""),
user_id=str(user_id or ""),
role=str(role or "member"),
channel=str(inbound.channel or "inbound"),
text=str(user_text or ""),
attachments=gw_attachments,
metadata={
"tenant_id": tenant_id,
"user_id": user_id,
"channel": inbound.channel,
"role": role,
"account_id": account_id,
"interaction_mode": interaction_mode,
"selected_specialist": selected_specialist,
"is_group": inbound.is_group,
"external_user_id": inbound.external_user_id,
"external_chat_id": inbound.external_chat_id,
"group_sender_id": inbound.external_user_id,
"mentioned_jids": mention_jids_for_ctx,
"mention_names": mention_names_for_ctx,
"raw_inbound_text": raw_user_text,
"bot_jid": str(meta_for_inbound.get("bot_jid") or bot_jid or ""),
"bot_lid": str(meta_for_inbound.get("bot_lid") or ""),
},
)
manager = _build_admin_gateway_executor(
store,
tenant_id=tenant_id,
user_id=user_id,
specialist=selected_specialist,
session_id=str(session_id),
lang=lang,
)
specialist_factory = lambda sid: _build_admin_gateway_executor(
store,
tenant_id=tenant_id,
user_id=user_id,
specialist=sid,
session_id=str(session_id),
lang=lang,
)
turn_result = gw.handle_turn(
msg=msg,
lang=lang,
executor=manager,
specialist_executor_factory=specialist_factory,
)
channel_turn_uuid = str(turn_result.turn_uuid or "").strip()
reply = str(turn_result.reply_text or "").strip()
reply_attachments = _collect_reply_attachments_from_history(
store=store,
session_id=str(session_id),
reply_text=reply,
)
except Exception as e:
reply = f"抱歉,处理消息时出错:{type(e).__name__}: {e}"
reply_attachments = []
interaction_mode, selected_specialist, dispatch_lang = _resolve_channel_dispatch(
store, channel=inbound.channel, account=account
)
lang = (
dispatch_lang
if dispatch_lang in {"zh", "en"}
else resolve_runtime_lang(store=store, user_text=user_text)
)
msg_metadata = {
"tenant_id": tenant_id,
"user_id": user_id,
"channel": inbound.channel,
"role": role,
"account_id": account_id,
"interaction_mode": interaction_mode,
"selected_specialist": selected_specialist,
"is_group": inbound.is_group,
"external_user_id": inbound.external_user_id,
"external_chat_id": inbound.external_chat_id,
"group_sender_id": inbound.external_user_id,
"mentioned_jids": mention_jids_for_ctx,
"mention_names": mention_names_for_ctx,
"raw_inbound_text": raw_user_text,
"bot_jid": str(meta_for_inbound.get("bot_jid") or bot_jid or ""),
"bot_lid": str(meta_for_inbound.get("bot_lid") or ""),
}
turn_job: dict[str, Any] = {
"user_text": str(user_text or ""),
"attachments": list(gw_attachments or []),
"msg_metadata": msg_metadata,
"lang": lang,
"interaction_mode": interaction_mode,
"selected_specialist": selected_specialist,
"inbound": inbound,
}
gate = get_channel_turn_gate()
turn_handle = None
if _channel_turn_serial_queue_enabled():
turn_handle = gate.try_begin(str(session_id), turn_job)
if turn_handle is None:
# Another turn is active; this message will be merged after it.
return {
"ok": True,
"replies": [],
"delivery": "accepted_queued",
}
gw = OclawGateway(store=store)
manager = _build_admin_gateway_executor(
store,
tenant_id=tenant_id,
user_id=user_id,
specialist=selected_specialist,
session_id=str(session_id),
lang=lang,
)
specialist_factory = lambda sid: _build_admin_gateway_executor(
store,
tenant_id=tenant_id,
user_id=user_id,
specialist=sid,
session_id=str(session_id),
lang=lang,
)
serial_replies: list[dict[str, Any]] = []
last_outbound_id = ""
wa_queue_delivery = (
str(inbound.channel or "").strip().lower() == "whatsapp"
and _whatsapp_inbound_queue_delivery_enabled()
)
try:
while True:
job_text = str(turn_job.get("user_text") or "")
job_atts = [
a for a in (turn_job.get("attachments") or []) if isinstance(a, dict)
]
job_meta = (
dict(turn_job.get("msg_metadata") or {})
if isinstance(turn_job.get("msg_metadata"), dict)
else dict(msg_metadata)
)
job_lang = str(turn_job.get("lang") or lang or "zh")
job_inbound = turn_job.get("inbound") or inbound
try:
turn_result = gw.handle_turn(
msg=StandardMessage(
session_id=str(session_id),
tenant_id=str(tenant_id or ""),
user_id=str(user_id or ""),
role=str(role or "member"),
channel=str(inbound.channel or "inbound"),
text=job_text,
attachments=job_atts,
metadata=job_meta,
),
lang=job_lang,
executor=manager,
specialist_executor_factory=specialist_factory,
)
channel_turn_uuid = str(turn_result.turn_uuid or "").strip()
turn_reply = str(turn_result.reply_text or "").strip()
turn_atts = _collect_reply_attachments_from_history(
store=store,
session_id=str(session_id),
reply_text=turn_reply,
)
except Exception as e:
turn_reply = f"抱歉,处理消息时出错:{type(e).__name__}: {e}"
turn_atts = []
reply_metadata: dict[str, Any] = {}
if (
str(inbound.channel or "").strip().lower() == "whatsapp"
and bool(getattr(job_inbound, "is_group", False))
):
from runtime.orchestration.group_ingest import (
build_whatsapp_group_reply_metadata,
)
reply_metadata = build_whatsapp_group_reply_metadata(
inbound=job_inbound
)
if wa_queue_delivery and (turn_reply or turn_atts):
try:
last_outbound_id = _enqueue_whatsapp_inbound_reply(
store,
inbound=job_inbound,
account_id=account_id,
tenant_id=str(tenant_id or ""),
reply_text=turn_reply,
reply_attachments=list(turn_atts or []),
reply_metadata=reply_metadata,
)
except Exception:
serial_replies.append(
{
"channel": inbound.channel,
"chat_id": inbound.external_chat_id,
"text": turn_reply,
"attachments": list(turn_atts or []),
"metadata": reply_metadata,
}
)
elif turn_reply or turn_atts:
serial_replies.append(
{
"channel": inbound.channel,
"chat_id": inbound.external_chat_id,
"text": turn_reply,
"attachments": list(turn_atts or []),
"metadata": reply_metadata,
}
)
# Keep last turn as the primary reply fields for non-queue paths.
reply = turn_reply
reply_attachments = list(turn_atts or [])
if turn_handle is None:
break
next_job, next_handle = gate.end_and_take_merged(turn_handle)
if not next_job or next_handle is None:
turn_handle = None
break
turn_job = next_job
turn_handle = next_handle
finally:
if turn_handle is not None:
try:
gate.force_end(turn_handle)
except Exception:
pass
if wa_queue_delivery and last_outbound_id and not serial_replies:
return {
"ok": True,
"replies": [],
"delivery": "queued",
"outbound_message_id": last_outbound_id,
}
if serial_replies and not wa_queue_delivery:
# Multiple serial turns: return all sync replies in order.
replies = serial_replies
# Skip the default single-reply assembly below.
if preface:
first = replies[0] if replies else None
if isinstance(first, dict) and first.get("text"):
first["text"] = f"{preface}\n\n{first.get('text')}"
elif preface:
replies.insert(
0,
{
"channel": inbound.channel,
"chat_id": inbound.external_chat_id,
"text": preface,
"attachments": [],
"metadata": {},
},
)
for r in replies or []:
if not isinstance(r, dict):
continue
ch = str(r.get("channel") or inbound.channel or "").strip().lower()
if ch in {"wechat", "weixin", "whatsapp"}:
_maybe_expand_reply_attachments_for_channel(r)
_maybe_add_media_path_for_wechat_reply(r)
return {"ok": True, "replies": replies}
else:
reply = "收到消息,但内容为空。请直接发送文本。"
reply_attachments = []
@ -1371,11 +1593,38 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
if not reply_attachments:
# If assistant didn't persist attachments, fall back to recent tool-produced media.
reply_attachments = _collect_recent_tool_attachments(store=store, session_id=str(session_id))
reply_metadata: dict[str, Any] = {}
reply_metadata = {}
if str(inbound.channel or "").strip().lower() == "whatsapp" and inbound.is_group:
from runtime.orchestration.group_ingest import build_whatsapp_group_reply_metadata
reply_metadata = build_whatsapp_group_reply_metadata(inbound=inbound)
# WhatsApp: deliver via outbound queue so long agent runs survive sidecar HTTP drops.
if (
str(inbound.channel or "").strip().lower() == "whatsapp"
and _whatsapp_inbound_queue_delivery_enabled()
and (reply or reply_attachments)
):
try:
msg_id = _enqueue_whatsapp_inbound_reply(
store,
inbound=inbound,
account_id=account_id,
tenant_id=str(tenant_id or ""),
reply_text=reply,
reply_attachments=list(reply_attachments or []),
reply_metadata=reply_metadata,
)
return {
"ok": True,
"replies": [],
"delivery": "queued",
"outbound_message_id": msg_id,
}
except Exception:
# Fall back to sync replies if enqueue fails.
pass
replies = [
{
"channel": inbound.channel,