Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

140 lines
4.9 KiB
Python

"""Stream a child process's stdout/stderr line-by-line as it runs.
The single low-level primitive the subagent backends share: spawn a command and
yield every stdout and stderr line the moment it arrives — so a long, multi-step
agent run surfaces live in the sidebar instead of all at once at the end — then
guarantee the process is torn down when the consumer stops early or the turn is
cancelled.
There is deliberately **no timeout** on the wait: per the product contract,
DeepTutor waits unconditionally for the subagent's own logic to finish; only the
subagent exiting (cleanly or with an error) ends the stream. Cancellation (the
user aborting the turn) propagates as ``CancelledError`` and the ``finally``
block terminates the child so no orphaned agent process is left behind.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Mapping, Sequence
import contextlib
import logging
import os
logger = logging.getLogger(__name__)
# (channel, text) where channel is "stdout", "stderr", or "exit" (the final
# item, whose text is the integer return code as a string).
ProcessLine = tuple[str, str]
_TERMINATE_GRACE_SECONDS = 5.0
async def stream_process_lines(
cmd: Sequence[str],
*,
cwd: str | None = None,
env: Mapping[str, str] | None = None,
) -> AsyncIterator[ProcessLine]:
"""Yield ``(channel, line)`` for each stdout/stderr line until the process exits.
The final item is always ``("exit", "<returncode>")`` so callers can tell a
clean finish from an early break. stdout and stderr are interleaved in
arrival order via a shared queue.
"""
full_env = {**os.environ, **(env or {})}
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=cwd or None,
env=full_env,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
queue: asyncio.Queue[ProcessLine | None] = asyncio.Queue()
async def _pump(stream: asyncio.StreamReader | None, channel: str) -> None:
if stream is None:
await queue.put(None)
return
try:
while True:
raw = await stream.readline()
if not raw:
break
await queue.put((channel, raw.decode("utf-8", "replace").rstrip("\r\n")))
except Exception: # pragma: no cover - defensive: a broken pipe must not hang the queue
logger.debug("subagent %s pump failed", channel, exc_info=True)
finally:
await queue.put(None) # sentinel: this channel is drained
readers = [
asyncio.create_task(_pump(process.stdout, "stdout")),
asyncio.create_task(_pump(process.stderr, "stderr")),
]
drained = 0
try:
while drained < len(readers):
item = await queue.get()
if item is None:
drained += 1
continue
yield item
returncode = await process.wait()
yield "exit", str(returncode)
finally:
for task in readers:
task.cancel()
with contextlib.suppress(Exception):
await asyncio.gather(*readers, return_exceptions=True)
await _terminate(process)
async def _terminate(process: asyncio.subprocess.Process) -> None:
"""Best-effort teardown: terminate, wait briefly, then kill."""
if process.returncode is not None:
return
with contextlib.suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=_TERMINATE_GRACE_SECONDS)
return
except (TimeoutError, asyncio.TimeoutError):
pass
except ProcessLookupError: # pragma: no cover
return
with contextlib.suppress(ProcessLookupError):
process.kill()
with contextlib.suppress(Exception):
await process.wait()
async def probe_version(cmd: Sequence[str], *, timeout: float = 8.0) -> tuple[bool, str]:
"""Run a fast ``--version``-style probe; return ``(ok, stdout-or-error)``.
Used by backend ``detect`` to answer "is this CLI installed here?" without
the no-timeout consult semantics — a probe that hangs is a failed probe.
"""
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
except FileNotFoundError:
return False, "not installed"
except Exception as exc: # pragma: no cover - defensive
return False, str(exc)
try:
out, _ = await asyncio.wait_for(process.communicate(), timeout=timeout)
except (TimeoutError, asyncio.TimeoutError):
await _terminate(process)
return False, "probe timed out"
text = (out or b"").decode("utf-8", "replace").strip()
return process.returncode == 0, text
__all__ = ["ProcessLine", "stream_process_lines", "probe_version"]