feat(voice): Pas 8 — threading.Lock per channel_id mutex + voice augment
Fix arhitectural general (beneficiu și pentru text adapters), nu doar voice.
src/claude_session.py:
- _session_locks: dict[str, threading.Lock] cu bootstrap lock pentru
lazy creation thread-safe.
- _get_session_lock(channel_id) helper.
- send_message() body wrapped în with _get_session_lock(channel_id).
- threading.Lock (NU asyncio.Lock) — send_message e sync subprocess.run
blocking; asyncio.Lock nu protejează cod sync rulat via to_thread.
- Per-channel granularity preserved — different channels run în paralel.
- send_message() public signature unchanged.
src/router.py:
- route_message(): dacă adapter_name == "discord-voice", prepend
[speaker:<user_name>] prefix (Config.get("voice.user_name", "user")).
- Original text variable left untouched for downstream paths.
- Text adapters: zero behavior change.
- route_message() public signature unchanged.
tests/test_claude_session_mutex.py — 6 tests REGRESSION-CRITICAL:
- same channel serializes (concurrent → mutex serializes, no overlap)
- same channel lock identity (same dict entry per channel_id)
- different channels run in parallel (overlap MUST fire)
- 3 channels all overlap
- contested acquire blocks then proceeds (policy: blocking, not fail-fast)
- lock released on subprocess exception (no deadlock on crash)
Acquisition policy: BLOCKING acquire bound by claude --timeout (5min default)
nu fail-fast — adapters already serialize via asyncio.to_thread queue, un
non-blocking acquire ar surface transient busy errors.
Test results: 82 passed (51 existing + 31 new). 2 PRE-EXISTING failures în
TestPromptInjectionProtection (stale assertion vs current prompt text) —
out of scope, recomand ticket separat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,42 @@ DEFAULT_TIMEOUT = 300 # seconds
|
||||
|
||||
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-channel mutex for send_message
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Two paths can hit `send_message(channel_id, ...)` concurrently for the same
|
||||
# channel: a text adapter (Discord/Telegram/WhatsApp) and the voice adapter
|
||||
# (`adapter_name="discord-voice"`). The underlying Claude CLI subprocess is
|
||||
# blocking (`subprocess.Popen` with stream-json read loop) and stateful via
|
||||
# `--resume <session_id>` — interleaving two concurrent invocations on the
|
||||
# same channel would corrupt the conversation order.
|
||||
#
|
||||
# We use `threading.Lock` (NOT `asyncio.Lock`) because `send_message` is sync
|
||||
# code typically run from `asyncio.to_thread` in async adapters. asyncio.Lock
|
||||
# only serializes coroutines, not threads — it would NOT protect this path.
|
||||
#
|
||||
# Each channel gets its own lock so DIFFERENT channels still run in parallel.
|
||||
# Locks are created lazily on first use; the dict itself is guarded by a
|
||||
# small bootstrap lock so two concurrent first-uses don't race on creation.
|
||||
_session_locks: dict[str, threading.Lock] = {}
|
||||
_session_locks_bootstrap = threading.Lock()
|
||||
|
||||
|
||||
def _get_session_lock(channel_id: str) -> threading.Lock:
|
||||
"""Return the channel's mutex, creating it on first access.
|
||||
|
||||
Two threads racing to create the same channel's lock would otherwise
|
||||
end up with different lock objects (setdefault is not atomic across
|
||||
the read-modify-write under all interpreter conditions — defensive).
|
||||
"""
|
||||
lock = _session_locks.get(channel_id)
|
||||
if lock is not None:
|
||||
return lock
|
||||
with _session_locks_bootstrap:
|
||||
return _session_locks.setdefault(channel_id, threading.Lock())
|
||||
|
||||
|
||||
PERSONALITY_FILES = [
|
||||
"IDENTITY.md",
|
||||
"SOUL.md",
|
||||
@@ -543,19 +579,28 @@ def send_message(
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
on_text: Callable[[str], None] | None = None,
|
||||
) -> str:
|
||||
"""High-level convenience: auto start or resume based on channel state."""
|
||||
session = get_active_session(channel_id)
|
||||
# Only resume if session has a valid session_id (not a pre-set model placeholder)
|
||||
if session is not None and session.get("session_id"):
|
||||
return resume_session(session["session_id"], message, timeout, on_text=on_text)
|
||||
# Use model from pre-set session if available, otherwise use provided model
|
||||
effective_model = model
|
||||
if session is not None and session.get("model"):
|
||||
effective_model = session["model"]
|
||||
response_text, _session_id = start_session(
|
||||
channel_id, message, effective_model, timeout, on_text=on_text
|
||||
)
|
||||
return response_text
|
||||
"""High-level convenience: auto start or resume based on channel state.
|
||||
|
||||
Concurrency: a per-`channel_id` `threading.Lock` serializes invocations
|
||||
that hit the same channel (e.g. text adapter + voice adapter racing on
|
||||
the same Discord guild text channel). Different channels run in
|
||||
parallel — each holds its own lock. Lock is acquired blocking; we rely
|
||||
on `timeout` (default 5 minutes) to bound the worst case rather than
|
||||
a non-blocking acquire (loss of fairness vs adapter-side queueing).
|
||||
"""
|
||||
with _get_session_lock(channel_id):
|
||||
session = get_active_session(channel_id)
|
||||
# Only resume if session has a valid session_id (not a pre-set model placeholder)
|
||||
if session is not None and session.get("session_id"):
|
||||
return resume_session(session["session_id"], message, timeout, on_text=on_text)
|
||||
# Use model from pre-set session if available, otherwise use provided model
|
||||
effective_model = model
|
||||
if session is not None and session.get("model"):
|
||||
effective_model = session["model"]
|
||||
response_text, _session_id = start_session(
|
||||
channel_id, message, effective_model, timeout, on_text=on_text
|
||||
)
|
||||
return response_text
|
||||
|
||||
|
||||
def clear_session(channel_id: str) -> bool:
|
||||
|
||||
@@ -154,8 +154,17 @@ def route_message(
|
||||
channel_cfg = _get_channel_config(channel_id)
|
||||
model = (channel_cfg or {}).get("default_model") or _get_config().get("bot.default_model", "sonnet")
|
||||
|
||||
# Voice-mode augment: prepend speaker prefix so Claude knows who spoke
|
||||
# in a voice channel. Cheap now, future-proof for multi-speaker later.
|
||||
# (Engineering decision #14 in the plan.) Only the discord-voice adapter
|
||||
# triggers it — text adapters keep the message verbatim.
|
||||
claude_text = text
|
||||
if adapter_name == "discord-voice":
|
||||
user_name = _get_config().get("voice.user_name", "user") or "user"
|
||||
claude_text = f"[speaker:{user_name}] {text}"
|
||||
|
||||
try:
|
||||
response = send_message(channel_id, text, model=model, on_text=on_text)
|
||||
response = send_message(channel_id, claude_text, model=model, on_text=on_text)
|
||||
_set_last_response(channel_id, response)
|
||||
return response, False
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user