fix(tools): use idle timeout for command TTS

This commit is contained in:
CleanDev-Fix 2026-06-21 05:26:44 -04:00 committed by Teknium
parent 43333acdda
commit 3884f078bd
2 changed files with 122 additions and 10 deletions

View file

@ -14,6 +14,8 @@ differences) Windows.
import json
import os
import shlex
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
@ -37,6 +39,7 @@ from tools.tts_tool import (
_render_command_tts_template,
_resolve_command_provider_config,
_resolve_max_text_length,
_run_command_tts,
_shell_quote_context,
check_tts_requirements,
text_to_speech_tool,
@ -57,6 +60,13 @@ def _python_copy_command(output_placeholder: str = "{output_path}") -> str:
)
def _shell_command(*args: str) -> str:
"""Return a shell command string for subprocess.Popen(shell=True)."""
if os.name == "nt":
return subprocess.list2cmdline(list(args))
return " ".join(shlex.quote(str(arg)) for arg in args)
# ---------------------------------------------------------------------------
# _resolve_command_provider_config / built-in precedence
# ---------------------------------------------------------------------------
@ -348,6 +358,53 @@ class TestRenderCommandTtsTemplate:
assert '"bob\'s voice"' in rendered
# ---------------------------------------------------------------------------
# _run_command_tts idle/progress timeout behavior
# ---------------------------------------------------------------------------
class TestRunCommandTts:
def test_stderr_progress_extends_beyond_timeout(self, tmp_path):
script = tmp_path / "progress_then_exit.py"
script.write_text(
"\n".join([
"import sys, time",
"for idx in range(4):",
" print(f'tick {idx}', file=sys.stderr, flush=True)",
" time.sleep(0.15)",
"print('done', flush=True)",
]),
encoding="utf-8",
)
result = _run_command_tts(
_shell_command(sys.executable, "-u", str(script)),
timeout=0.25,
)
assert result.returncode == 0
assert "tick 3" in result.stderr
assert "done" in result.stdout
def test_silent_after_progress_still_times_out_with_stderr(self, tmp_path):
script = tmp_path / "progress_then_hang.py"
script.write_text(
"\n".join([
"import sys, time",
"print('starting tier 1', file=sys.stderr, flush=True)",
"time.sleep(1.0)",
]),
encoding="utf-8",
)
with pytest.raises(subprocess.TimeoutExpired) as excinfo:
_run_command_tts(
_shell_command(sys.executable, "-u", str(script)),
timeout=0.2,
)
assert "starting tier 1" in (excinfo.value.stderr or "")
# ---------------------------------------------------------------------------
# End-to-end: _generate_command_tts
# ---------------------------------------------------------------------------

View file

@ -48,6 +48,7 @@ import shutil
import subprocess
import tempfile
import threading
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
@ -999,7 +1000,7 @@ def _terminate_command_tts_process_tree(proc: subprocess.Popen) -> None:
def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProcess:
"""Run a command-provider shell command with process-tree timeout cleanup."""
"""Run a command-provider shell command with process-tree idle cleanup."""
from agent.delegation_context import delegated_child_subprocess_env
popen_kwargs: Dict[str, Any] = {
@ -1019,21 +1020,75 @@ def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProces
popen_kwargs["start_new_session"] = True
proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired as exc:
_terminate_command_tts_process_tree(proc)
output_queue: "queue.Queue[tuple[str, Optional[str]]]" = queue.Queue()
chunks: Dict[str, list[str]] = {"stdout": [], "stderr": []}
open_streams = {"stdout", "stderr"}
def read_stream(name: str, stream: Any) -> None:
try:
stdout, stderr = proc.communicate(timeout=1)
except Exception:
stdout = getattr(exc, "output", None)
stderr = getattr(exc, "stderr", None)
while True:
chunk = stream.read(1)
if not chunk:
break
output_queue.put((name, chunk))
finally:
output_queue.put((name, None))
readers = [
threading.Thread(
target=read_stream,
args=("stdout", proc.stdout),
daemon=True,
),
threading.Thread(
target=read_stream,
args=("stderr", proc.stderr),
daemon=True,
),
]
for reader in readers:
reader.start()
deadline = time.monotonic() + timeout
timed_out = False
while open_streams:
remaining = deadline - time.monotonic()
if remaining <= 0:
timed_out = True
break
try:
name, chunk = output_queue.get(timeout=min(0.05, remaining))
except queue.Empty:
continue
if chunk is None:
open_streams.discard(name)
continue
chunks[name].append(chunk)
deadline = time.monotonic() + timeout
if timed_out:
_terminate_command_tts_process_tree(proc)
for reader in readers:
reader.join(timeout=0.5)
while True:
try:
name, chunk = output_queue.get_nowait()
except queue.Empty:
break
if chunk:
chunks[name].append(chunk)
stdout = "".join(chunks["stdout"])
stderr = "".join(chunks["stderr"])
raise subprocess.TimeoutExpired(
command,
timeout,
output=stdout,
stderr=stderr,
) from exc
)
stdout = "".join(chunks["stdout"])
stderr = "".join(chunks["stderr"])
proc.wait()
if proc.returncode:
raise subprocess.CalledProcessError(