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:
2026-05-27 14:43:05 +00:00
parent a3eefbc799
commit 3af6bcaea4
3 changed files with 375 additions and 14 deletions

View File

@@ -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:

View File

@@ -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: