Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
364 lines
15 KiB
Python
364 lines
15 KiB
Python
"""Runtime dependency helper.
|
|
|
|
Tasks and document extraction should never ask the user to install support
|
|
libraries by hand — when something is missing we try to ``pip install`` it into
|
|
the running interpreter automatically. In a packaged (frozen) build pip isn't
|
|
available, so callers must still degrade gracefully if this returns None/False.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Callable, Dict, List, Optional, Tuple
|
|
|
|
CancelFn = Callable[[], bool]
|
|
|
|
_POLL_SECS = 0.2
|
|
|
|
_FAILED: set[str] = set() # packages we already tried and couldn't install
|
|
|
|
# 🔒 Sandbox Security Layer — Resource Usage: every subprocess spawned via
|
|
# run_cancellable registers its pid here for the duration of the run, so the
|
|
# Monitoring Dashboard can show live psutil stats without its own tracking.
|
|
_active_pids_lock = threading.Lock()
|
|
_ACTIVE_PIDS: set[int] = set()
|
|
|
|
|
|
def active_pids() -> List[int]:
|
|
"""Pid của các tiến trình con đang chạy — dùng để dọn sạch khi thoát app."""
|
|
with _active_pids_lock:
|
|
return sorted(_ACTIVE_PIDS)
|
|
|
|
# Substrings (lower-cased) in pip's output that mark a TRANSIENT failure (flaky
|
|
# network) worth silently retrying, as opposed to a deterministic one (bad
|
|
# package name, no matching version, syntax error in a requirement) where
|
|
# retrying would just waste time and reproduce the same error.
|
|
_TRANSIENT_MARKERS = (
|
|
"connection reset", "connection aborted", "connection refused",
|
|
"read timed out", "timed out", "temporary failure", "getaddrinfo failed",
|
|
"could not fetch url", "network is unreachable", "max retries exceeded",
|
|
"remote end closed connection", "econnreset",
|
|
)
|
|
|
|
|
|
def _kill_tree(proc: "subprocess.Popen", job_handle: Optional[int] = None) -> None:
|
|
"""Kill a subprocess AND any children it spawned (e.g. a shell wrapping the
|
|
real command, or a build tool that forks workers) — plain ``proc.kill()``
|
|
only kills the direct child and would leave the real work running.
|
|
|
|
``job_handle`` (Windows only), when the process was successfully assigned
|
|
to one at spawn time, is tried FIRST — a Job Object catches reparented/
|
|
detached processes that ``taskkill /T``'s PID-tree walk can miss (see
|
|
win_job.py). Falls back to ``taskkill /T`` if there's no job handle."""
|
|
if sys.platform == "win32" and job_handle:
|
|
from .win_job import terminate_job
|
|
|
|
if terminate_job(job_handle):
|
|
try:
|
|
proc.kill()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return
|
|
try:
|
|
if sys.platform == "win32":
|
|
subprocess.run(
|
|
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
capture_output=True, timeout=10,
|
|
)
|
|
else:
|
|
import os
|
|
import signal
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
except Exception: # noqa: BLE001 - killing must never itself raise
|
|
pass
|
|
finally:
|
|
try:
|
|
proc.kill()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
|
|
def run_cancellable(
|
|
args, *, cwd: str | None = None, timeout: float | None = None,
|
|
cancel: Optional[CancelFn] = None, shell: bool = False,
|
|
on_output: Optional[Callable[[str], None]] = None,
|
|
env: Optional[Dict[str, str]] = None,
|
|
limits: Optional[Dict[str, float]] = None,
|
|
) -> Tuple[Optional[int], str, bool, bool, bool]:
|
|
"""Run a subprocess so the Stop button can actually interrupt it.
|
|
|
|
``subprocess.run(..., timeout=...)`` blocks the calling thread until the
|
|
process exits or the timeout fires — the cooperative cancel flag checked
|
|
elsewhere in the agent loop has no chance to run, so Stop appears to do
|
|
nothing while a command (or ``pip install``) is executing. This polls
|
|
``cancel()`` every ``_POLL_SECS`` instead and kills the whole process tree
|
|
the moment the user stops, or the timeout is hit.
|
|
|
|
``on_output``, if given, is called with each line of stdout/stderr AS IT
|
|
ARRIVES (not just at the end) so a caller can stream live progress to the
|
|
UI for long-running commands — purely a side channel; the return value is
|
|
unaffected.
|
|
|
|
``limits`` (Sandbox Security Layer — see ``resource_limits.py``), if
|
|
given, is a dict of any subset of ``cpu_percent``/``memory_mb``/
|
|
``disk_mb``: the process TREE's usage is polled on the same cadence as
|
|
cancel/timeout, and the tree is killed the moment a cap is exceeded.
|
|
|
|
On Windows, the process is additionally assigned to a Job Object at spawn
|
|
time (see ``win_job.py``) — a stronger tree-kill than ``taskkill /T``
|
|
alone, since it also catches reparented/detached children. Best-effort:
|
|
a failure to create/assign the job just means the existing taskkill
|
|
fallback is used, same as before this was added.
|
|
|
|
Returns ``(returncode, combined_output, cancelled, timed_out,
|
|
resource_exceeded)``; on a failure to even launch the process,
|
|
``returncode`` is ``None`` and the output holds the launch error."""
|
|
cancel = cancel or (lambda: False)
|
|
popen_kwargs = {} if sys.platform == "win32" else {"start_new_session": True}
|
|
try:
|
|
proc = subprocess.Popen(
|
|
args, shell=shell, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
text=True, bufsize=1, env=env, **popen_kwargs,
|
|
)
|
|
except OSError as exc:
|
|
return None, str(exc), False, False, False
|
|
|
|
with _active_pids_lock:
|
|
_ACTIVE_PIDS.add(proc.pid)
|
|
try:
|
|
return _run_cancellable_body(proc, cancel, timeout, on_output, limits)
|
|
finally:
|
|
with _active_pids_lock:
|
|
_ACTIVE_PIDS.discard(proc.pid)
|
|
|
|
|
|
def _run_cancellable_body(
|
|
proc: "subprocess.Popen", cancel: CancelFn, timeout: Optional[float],
|
|
on_output: Optional[Callable[[str], None]], limits: Optional[Dict[str, float]],
|
|
) -> Tuple[Optional[int], str, bool, bool, bool]:
|
|
"""Chạy một tiến trình con có thể huỷ giữa chừng, có hạn giờ và có giới hạn tài nguyên.
|
|
|
|
Trên Windows gắn tiến trình vào một Job Object để khi giết là giết cả cây
|
|
tiến trình con — giết mỗi tiến trình cha sẽ để lại đám con mồ côi.
|
|
"""
|
|
job_handle = None
|
|
if sys.platform == "win32":
|
|
from .win_job import assign_process, create_job_object
|
|
|
|
job_handle = create_job_object()
|
|
if job_handle is not None:
|
|
assign_process(job_handle, proc.pid)
|
|
|
|
if limits:
|
|
from .resource_limits import prime_cpu_counter
|
|
|
|
prime_cpu_counter(proc.pid)
|
|
|
|
collected: Dict[str, list] = {"out": [], "err": []}
|
|
|
|
def _read_stream(stream, key: str) -> None:
|
|
"""Đọc một luồng đầu ra theo từng dòng ở luồng riêng.
|
|
|
|
Phải đọc song song stdout và stderr: đọc lần lượt sẽ kẹt khi tiến trình con
|
|
làm đầy bộ đệm của luồng còn lại.
|
|
"""
|
|
try:
|
|
for line in iter(stream.readline, ""):
|
|
collected[key].append(line)
|
|
if on_output is not None:
|
|
try:
|
|
on_output(line)
|
|
except Exception: # noqa: BLE001 - a UI callback must never kill the tool
|
|
pass
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
finally:
|
|
try:
|
|
stream.close()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
out_thread = threading.Thread(target=_read_stream, args=(proc.stdout, "out"), daemon=True)
|
|
err_thread = threading.Thread(target=_read_stream, args=(proc.stderr, "err"), daemon=True)
|
|
out_thread.start()
|
|
err_thread.start()
|
|
|
|
start = time.monotonic()
|
|
cancelled = timed_out = resource_exceeded = False
|
|
resource_reason = ""
|
|
while out_thread.is_alive() or err_thread.is_alive():
|
|
if cancel():
|
|
cancelled = True
|
|
_kill_tree(proc, job_handle)
|
|
break
|
|
if timeout is not None and (time.monotonic() - start) > timeout:
|
|
timed_out = True
|
|
_kill_tree(proc, job_handle)
|
|
break
|
|
if limits:
|
|
from .resource_limits import check_limits
|
|
|
|
resource_reason = check_limits(proc.pid, limits) or ""
|
|
if resource_reason:
|
|
resource_exceeded = True
|
|
_kill_tree(proc, job_handle)
|
|
break
|
|
time.sleep(_POLL_SECS)
|
|
out_thread.join(timeout=5)
|
|
err_thread.join(timeout=5)
|
|
try:
|
|
proc.wait(timeout=5) # reap so returncode is populated
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
|
|
out, err = "".join(collected["out"]), "".join(collected["err"])
|
|
combined = out + (("\n[stderr]\n" + err) if err else "")
|
|
if resource_exceeded:
|
|
combined += f"\n[resource limit] {resource_reason}\n"
|
|
return proc.returncode, combined, cancelled, timed_out, resource_exceeded
|
|
|
|
|
|
def network_blocked_env(base_env: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
"""Env vars that make well-behaved HTTP clients refuse to reach the
|
|
network — proxy vars pointed at a black-hole loopback port nothing
|
|
listens on (connection refused instantly, no hang).
|
|
|
|
This is a POLICY-level control (Sandbox Security Layer — "Network
|
|
Control"), not a kernel firewall: it stops the vast majority of scripted
|
|
network calls (``requests``/``curl``/``wget``/``npm``/``pip`` all honor
|
|
these standard proxy env vars) without requiring admin rights or a
|
|
bundled driver — a tool that ignores proxy env vars entirely (rare, but
|
|
possible) would still get through. Combine with the Agent Security
|
|
command whitelist for defense in depth."""
|
|
import os
|
|
|
|
env = dict(base_env if base_env is not None else os.environ)
|
|
blackhole = "http://127.0.0.1:1"
|
|
for key in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"):
|
|
env[key] = blackhole
|
|
env["NO_PROXY"] = ""
|
|
env["no_proxy"] = ""
|
|
return env
|
|
|
|
|
|
def _can_pip() -> bool:
|
|
# A PyInstaller/py2exe build has no usable pip; don't attempt installs there.
|
|
"""Bản đóng gói (PyInstaller) không có pip dùng được — đừng thử cài gì ở đó."""
|
|
return not getattr(sys, "frozen", False)
|
|
|
|
|
|
def ensure_module(module: str, package: str | None = None):
|
|
"""Import ``module``, auto-installing ``package`` (pip) first if needed.
|
|
|
|
Returns the imported module, or None if it isn't available and can't be
|
|
installed (offline, no pip, frozen build, …)."""
|
|
try:
|
|
return importlib.import_module(module)
|
|
except ImportError:
|
|
pass
|
|
pkg = package or module
|
|
if pkg in _FAILED or not _can_pip():
|
|
return None
|
|
ok, _ = pip_install(pkg)
|
|
if not ok:
|
|
_FAILED.add(pkg)
|
|
return None
|
|
try:
|
|
importlib.invalidate_caches()
|
|
return importlib.import_module(module)
|
|
except ImportError:
|
|
_FAILED.add(pkg)
|
|
return None
|
|
|
|
|
|
def venv_python_path(venv_dir: Path) -> Path:
|
|
"""Đường dẫn tới ``python`` trong một virtualenv, khác nhau giữa Windows và POSIX."""
|
|
return venv_dir / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")
|
|
|
|
|
|
def ensure_project_venv(workdir: Path, cancel: Optional[CancelFn] = None,
|
|
on_output: Optional[Callable[[str], None]] = None) -> Optional[Path]:
|
|
"""Create (if missing) and return the interpreter of a per-project sandbox
|
|
virtual environment at ``<workdir>/.venv`` — so packages the Code agent
|
|
installs for one project never leak into another project's runs or into
|
|
the app's own environment. Returns None (caller should fall back to the
|
|
app's own interpreter) when a venv can't be created (offline, no pip, a
|
|
packaged/frozen build, ...) — sandboxing is a nice-to-have, never a hard
|
|
requirement for the agent to keep working."""
|
|
if not _can_pip():
|
|
return None
|
|
venv_dir = workdir / ".venv"
|
|
py = venv_python_path(venv_dir)
|
|
if py.exists():
|
|
return py
|
|
if on_output is not None:
|
|
on_output("[sandbox] creating project virtual environment (.venv)…\n")
|
|
returncode, out, cancelled, _, _ = run_cancellable(
|
|
[sys.executable, "-m", "venv", str(venv_dir)],
|
|
timeout=120, cancel=cancel, on_output=on_output,
|
|
)
|
|
if returncode == 0 and py.exists():
|
|
return py
|
|
if on_output is not None:
|
|
on_output(f"[sandbox] could not create .venv, using the app's own environment ({out.strip()[-300:]})\n")
|
|
return None
|
|
|
|
|
|
def sandbox_env(python_path: Path) -> Dict[str, str]:
|
|
"""Env vars that make a subprocess behave as if this venv were activated —
|
|
bare ``python``/``pip`` in a shell command then resolve to the sandbox."""
|
|
import os
|
|
|
|
env = dict(os.environ)
|
|
bin_dir = str(Path(python_path).parent)
|
|
env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")
|
|
env["VIRTUAL_ENV"] = str(Path(python_path).parent.parent)
|
|
env.pop("PYTHONHOME", None)
|
|
return env
|
|
|
|
|
|
def pip_install(package: str, cancel: Optional[CancelFn] = None,
|
|
on_output: Optional[Callable[[str], None]] = None,
|
|
python: Optional[str] = None, retries: int = 2) -> tuple[bool, str]:
|
|
"""Install a pip package into ``python`` (default: the app's own interpreter).
|
|
Returns (ok, output).
|
|
|
|
Cancellable (see :func:`run_cancellable`) so hitting Stop while a package is
|
|
installing actually kills pip instead of blocking until it finishes.
|
|
|
|
A failure that looks like a flaky network blip (connection reset, timeout,
|
|
DNS failure...) is retried automatically up to ``retries`` times with a
|
|
short backoff — a deterministic failure (no matching version, bad package
|
|
name) is NOT retried, since repeating it would just waste time."""
|
|
if not _can_pip():
|
|
return False, "This packaged build can't install packages at runtime."
|
|
exe = python or sys.executable
|
|
attempt = 0
|
|
while True:
|
|
attempt += 1
|
|
returncode, out, cancelled, timed_out, _ = run_cancellable(
|
|
[exe, "-m", "pip", "install", "--disable-pip-version-check", package],
|
|
timeout=600, cancel=cancel, on_output=on_output,
|
|
)
|
|
if returncode is None:
|
|
return False, f"pip failed to run: {out}"
|
|
if cancelled:
|
|
return False, "Installation cancelled by user."
|
|
if timed_out:
|
|
return False, "pip install timed out (600s) and was cancelled."
|
|
if returncode == 0:
|
|
return True, (out.strip()[-4000:] or "(no output)")
|
|
transient = any(marker in out.lower() for marker in _TRANSIENT_MARKERS)
|
|
if not transient or attempt > retries or (cancel and cancel()):
|
|
return False, (out.strip()[-4000:] or "(no output)")
|
|
if on_output is not None:
|
|
on_output(f"\n[retry] transient network error — retrying ({attempt}/{retries})…\n")
|
|
time.sleep(1.5 * attempt)
|