|
@@ -94,6 +94,9 @@ class AssistantService:
|
|
|
|
|
|
|
|
self._last_emitted: tuple = ()
|
|
self._last_emitted: tuple = ()
|
|
|
self._stop = threading.Event()
|
|
self._stop = threading.Event()
|
|
|
|
|
+ # Guards mode transitions, the command buffer, and the chat buffers
|
|
|
|
|
+ # against the reader, ticker, and control-socket threads.
|
|
|
|
|
+ self._lock = threading.RLock()
|
|
|
self._reader: threading.Thread | None = None
|
|
self._reader: threading.Thread | None = None
|
|
|
self._ticker: threading.Thread | None = None
|
|
self._ticker: threading.Thread | None = None
|
|
|
self._exec_thread: threading.Thread | None = None
|
|
self._exec_thread: threading.Thread | None = None
|
|
@@ -102,6 +105,8 @@ class AssistantService:
|
|
|
|
|
|
|
|
def start(self) -> None:
|
|
def start(self) -> None:
|
|
|
"""Start the event reader and the timeout ticker."""
|
|
"""Start the event reader and the timeout ticker."""
|
|
|
|
|
+ if self._reader is not None or self._ticker is not None:
|
|
|
|
|
+ return
|
|
|
self._reader = threading.Thread(
|
|
self._reader = threading.Thread(
|
|
|
target=self._read_loop, name="assistant-events", daemon=True
|
|
target=self._read_loop, name="assistant-events", daemon=True
|
|
|
)
|
|
)
|
|
@@ -190,29 +195,32 @@ class AssistantService:
|
|
|
|
|
|
|
|
def edit(self, text: str) -> bool:
|
|
def edit(self, text: str) -> bool:
|
|
|
"""Replace the command buffer text (refused unless command-armed)."""
|
|
"""Replace the command buffer text (refused unless command-armed)."""
|
|
|
- if self.modes.mode is not Mode.COMMAND_ARMED:
|
|
|
|
|
- return False
|
|
|
|
|
- self.command_text = text
|
|
|
|
|
- self.command_edited = True
|
|
|
|
|
|
|
+ with self._lock:
|
|
|
|
|
+ if self.modes.mode is not Mode.COMMAND_ARMED:
|
|
|
|
|
+ return False
|
|
|
|
|
+ self.command_text = text
|
|
|
|
|
+ self.command_edited = True
|
|
|
self._emit_command_text(force=True)
|
|
self._emit_command_text(force=True)
|
|
|
return True
|
|
return True
|
|
|
|
|
|
|
|
def arm(self) -> bool:
|
|
def arm(self) -> bool:
|
|
|
"""Enter command mode (refused unless idle)."""
|
|
"""Enter command mode (refused unless idle)."""
|
|
|
- if not self.modes.arm():
|
|
|
|
|
- return False
|
|
|
|
|
- self.arm_ts = self._now()
|
|
|
|
|
- self.command_text = ""
|
|
|
|
|
- self.command_edited = False
|
|
|
|
|
|
|
+ with self._lock:
|
|
|
|
|
+ if not self.modes.arm():
|
|
|
|
|
+ return False
|
|
|
|
|
+ self.arm_ts = self._now()
|
|
|
|
|
+ self.command_text = ""
|
|
|
|
|
+ self.command_edited = False
|
|
|
self._emit_mode()
|
|
self._emit_mode()
|
|
|
self._emit_command_text(force=True)
|
|
self._emit_command_text(force=True)
|
|
|
return True
|
|
return True
|
|
|
|
|
|
|
|
def cancel(self) -> bool:
|
|
def cancel(self) -> bool:
|
|
|
"""Leave command mode, discarding the buffer."""
|
|
"""Leave command mode, discarding the buffer."""
|
|
|
- if not self.modes.cancel():
|
|
|
|
|
- return False
|
|
|
|
|
- self._clear_command()
|
|
|
|
|
|
|
+ with self._lock:
|
|
|
|
|
+ if not self.modes.cancel():
|
|
|
|
|
+ return False
|
|
|
|
|
+ self._clear_command()
|
|
|
self._emit_mode()
|
|
self._emit_mode()
|
|
|
self._emit_command_text(force=True)
|
|
self._emit_command_text(force=True)
|
|
|
return True
|
|
return True
|
|
@@ -223,12 +231,15 @@ class AssistantService:
|
|
|
When an executor is configured, execution runs on a worker thread so the
|
|
When an executor is configured, execution runs on a worker thread so the
|
|
|
caller (control socket) is never blocked.
|
|
caller (control socket) is never blocked.
|
|
|
"""
|
|
"""
|
|
|
- if self.modes.mode is not Mode.COMMAND_ARMED or not self.ready():
|
|
|
|
|
- return None
|
|
|
|
|
- text = self.command_text
|
|
|
|
|
- edited = self.command_edited
|
|
|
|
|
- if not self.modes.execute():
|
|
|
|
|
- return None
|
|
|
|
|
|
|
+ # The readiness check and the mode transition must be atomic: the
|
|
|
|
|
+ # reader thread mutates speech_open/awaited_seg_id concurrently.
|
|
|
|
|
+ with self._lock:
|
|
|
|
|
+ if self.modes.mode is not Mode.COMMAND_ARMED or not self.ready():
|
|
|
|
|
+ return None
|
|
|
|
|
+ text = self.command_text
|
|
|
|
|
+ edited = self.command_edited
|
|
|
|
|
+ if not self.modes.execute():
|
|
|
|
|
+ return None
|
|
|
self._emit_mode()
|
|
self._emit_mode()
|
|
|
self._emit_command_text(force=True)
|
|
self._emit_command_text(force=True)
|
|
|
if self.executor is not None:
|
|
if self.executor is not None:
|
|
@@ -278,9 +289,10 @@ class AssistantService:
|
|
|
|
|
|
|
|
def finish(self) -> bool:
|
|
def finish(self) -> bool:
|
|
|
"""Leave `executing` and discard the buffer."""
|
|
"""Leave `executing` and discard the buffer."""
|
|
|
- if not self.modes.finish():
|
|
|
|
|
- return False
|
|
|
|
|
- self._clear_command()
|
|
|
|
|
|
|
+ with self._lock:
|
|
|
|
|
+ if not self.modes.finish():
|
|
|
|
|
+ return False
|
|
|
|
|
+ self._clear_command()
|
|
|
self._emit_mode()
|
|
self._emit_mode()
|
|
|
self._emit_command_text(force=True)
|
|
self._emit_command_text(force=True)
|
|
|
return True
|
|
return True
|
|
@@ -289,11 +301,12 @@ class AssistantService:
|
|
|
"""Enter chat mode from idle, or leave chat mode."""
|
|
"""Enter chat mode from idle, or leave chat mode."""
|
|
|
if self.modes.mode is Mode.CHAT:
|
|
if self.modes.mode is Mode.CHAT:
|
|
|
return self._exit_chat("toggle")
|
|
return self._exit_chat("toggle")
|
|
|
- if not self.modes.enter_chat():
|
|
|
|
|
- return False
|
|
|
|
|
- self.chat_history = []
|
|
|
|
|
- self._pending_chat = []
|
|
|
|
|
- self._last_chat_activity = self._now()
|
|
|
|
|
|
|
+ with self._lock:
|
|
|
|
|
+ if not self.modes.enter_chat():
|
|
|
|
|
+ return False
|
|
|
|
|
+ self.chat_history = []
|
|
|
|
|
+ self._pending_chat = []
|
|
|
|
|
+ self._last_chat_activity = self._now()
|
|
|
self._emit_mode()
|
|
self._emit_mode()
|
|
|
return True
|
|
return True
|
|
|
|
|
|
|
@@ -376,11 +389,13 @@ class AssistantService:
|
|
|
ts = float(event.get("ts", now))
|
|
ts = float(event.get("ts", now))
|
|
|
# Ignore transcripts from before arming (e.g. ring replay on attach).
|
|
# Ignore transcripts from before arming (e.g. ring replay on attach).
|
|
|
if self.arm_ts is None or ts >= self.arm_ts:
|
|
if self.arm_ts is None or ts >= self.arm_ts:
|
|
|
- self.command_text = f"{self.command_text} {text}".strip()
|
|
|
|
|
|
|
+ with self._lock:
|
|
|
|
|
+ self.command_text = f"{self.command_text} {text}".strip()
|
|
|
elif self.modes.mode is Mode.CHAT:
|
|
elif self.modes.mode is Mode.CHAT:
|
|
|
- self.chat_history.append(text)
|
|
|
|
|
- self._pending_chat.append(text)
|
|
|
|
|
- self._last_chat_activity = now
|
|
|
|
|
|
|
+ with self._lock:
|
|
|
|
|
+ self.chat_history.append(text)
|
|
|
|
|
+ self._pending_chat.append(text)
|
|
|
|
|
+ self._last_chat_activity = now
|
|
|
if self._is_exit_phrase(text):
|
|
if self._is_exit_phrase(text):
|
|
|
self._exit_chat("exit_phrase")
|
|
self._exit_chat("exit_phrase")
|
|
|
self._emit_command_text()
|
|
self._emit_command_text()
|
|
@@ -408,18 +423,20 @@ class AssistantService:
|
|
|
# ------------------------------------------------------------------ helpers
|
|
# ------------------------------------------------------------------ helpers
|
|
|
|
|
|
|
|
def _disarm(self, reason: str) -> None:
|
|
def _disarm(self, reason: str) -> None:
|
|
|
- if not self.modes.cancel():
|
|
|
|
|
- return
|
|
|
|
|
- self._clear_command()
|
|
|
|
|
|
|
+ with self._lock:
|
|
|
|
|
+ if not self.modes.cancel():
|
|
|
|
|
+ return
|
|
|
|
|
+ self._clear_command()
|
|
|
self._emit_mode()
|
|
self._emit_mode()
|
|
|
self._emit_command_text(force=True)
|
|
self._emit_command_text(force=True)
|
|
|
self._emit(ERROR_EVENT, code=reason, message=f"command {reason}")
|
|
self._emit(ERROR_EVENT, code=reason, message=f"command {reason}")
|
|
|
|
|
|
|
|
def _exit_chat(self, reason: str) -> bool:
|
|
def _exit_chat(self, reason: str) -> bool:
|
|
|
- if not self.modes.exit_chat():
|
|
|
|
|
- return False
|
|
|
|
|
- self.chat_history = []
|
|
|
|
|
- self._pending_chat = []
|
|
|
|
|
|
|
+ with self._lock:
|
|
|
|
|
+ if not self.modes.exit_chat():
|
|
|
|
|
+ return False
|
|
|
|
|
+ self.chat_history = []
|
|
|
|
|
+ self._pending_chat = []
|
|
|
if self.chat_reset is not None:
|
|
if self.chat_reset is not None:
|
|
|
try:
|
|
try:
|
|
|
self.chat_reset()
|
|
self.chat_reset()
|
|
@@ -431,8 +448,9 @@ class AssistantService:
|
|
|
return True
|
|
return True
|
|
|
|
|
|
|
|
def _dispatch_chat(self) -> None:
|
|
def _dispatch_chat(self) -> None:
|
|
|
- text = " ".join(self._pending_chat).strip()
|
|
|
|
|
- self._pending_chat = []
|
|
|
|
|
|
|
+ with self._lock:
|
|
|
|
|
+ text = " ".join(self._pending_chat).strip()
|
|
|
|
|
+ self._pending_chat = []
|
|
|
if not text:
|
|
if not text:
|
|
|
return
|
|
return
|
|
|
self._emit(CHAT_MESSAGE_EVENT, role="user", text=text)
|
|
self._emit(CHAT_MESSAGE_EVENT, role="user", text=text)
|