mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-07-27 01:22:16 +00:00
Settle the Playwright-grade reliability contract in agent-desktop-core before the Windows/Linux adapters are built, so they inherit it instead of redesigning it. Every command now observes, waits, verifies, and reports honestly instead of firing blindly. Highlights: capability-supertrait split of PlatformAdapter with not_supported() defaults; canonical role/state vocabulary with live `is --property visible`; display enumeration (`list-displays`) and honest `--screen` with scale factor; truthful Automation permission; `native_id` identity spine; window-id-first resolution; serializable `LocatorQuery` with live `find`; default-on auto-wait before every ref action; three-way `hit_test` occlusion gate; `scroll_into_view` in core; core accessible-name precedence; typed `ActionStep` delivery tier; `ProcessState` and `APP_UNRESPONSIVE`; `LaunchOptions`; baseline-diff desktop signals (`wait --event`); typed clipboard (`Text`/`Image`/`FileUrls`); mouse modifier chords and `mouse-wheel`. Hardened through a 35-reviewer pass with independent validation and a green live e2e gate (109/0), plus a head-vs-main performance comparison harness. BREAKING CHANGE: default-on auto-wait changes the timing of every previously-untouched ref-action call (bounded 5000 ms default; `--timeout-ms 0` restores single-shot). `ENVELOPE_VERSION` is now `2.1` (adds the `APP_UNRESPONSIVE` code and process state in error details). FFI ABI major is `3` (append-only struct evolution; `wait --event` is intentionally not exposed over FFI). The legacy string clipboard API is removed in favor of typed content. `key-down`/`key-up` fail closed until daemon-owned held input exists. `close-app` verifies termination and the osascript fallback path is removed. `--text` matching is subtree containment: `find --text X --first` returns the outermost matching container.
212 lines
6.6 KiB
Python
212 lines
6.6 KiB
Python
import dataclasses
|
|
import os
|
|
import resource
|
|
import selectors
|
|
import signal
|
|
import subprocess
|
|
import time
|
|
|
|
|
|
DEFAULT_TIMEOUT_SECONDS = 20.0
|
|
DEFAULT_MAX_CAPTURE_BYTES = 2 * 1024 * 1024
|
|
INTERACTION_LEASE_FD_ENV = "AGENT_DESKTOP_INTERACTION_LEASE_FD"
|
|
INHERIT_INTERACTION_LEASE_ENV = "AGENT_DESKTOP_E2E_INHERIT_LEASE"
|
|
|
|
|
|
@dataclasses.dataclass
|
|
class BoundedResult:
|
|
args: list[str]
|
|
returncode: int
|
|
stdout: str
|
|
stderr: str
|
|
timed_out: bool
|
|
output_limited: bool
|
|
termination_error: str | None
|
|
wall_ms: float
|
|
cpu_ms: float
|
|
|
|
|
|
def _terminate_group(process):
|
|
process_group = process.pid
|
|
try:
|
|
os.killpg(process_group, signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
return None
|
|
except OSError as error:
|
|
try:
|
|
process.terminate()
|
|
except OSError:
|
|
pass
|
|
return f"process-group SIGTERM failed: {error}"
|
|
grace_deadline = time.monotonic() + 0.25
|
|
while time.monotonic() < grace_deadline:
|
|
try:
|
|
os.killpg(process_group, 0)
|
|
except ProcessLookupError:
|
|
process.poll()
|
|
return None
|
|
except OSError:
|
|
break
|
|
time.sleep(0.01)
|
|
try:
|
|
os.killpg(process_group, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
return None
|
|
except OSError as error:
|
|
try:
|
|
process.kill()
|
|
except OSError:
|
|
pass
|
|
return f"process-group SIGKILL failed: {error}"
|
|
return None
|
|
|
|
|
|
def _merge_error(current, additional):
|
|
if not additional:
|
|
return current
|
|
return f"{current}; {additional}" if current else additional
|
|
|
|
|
|
def _interaction_lease_fds(env):
|
|
environment = os.environ if env is None else env
|
|
raw_fd = environment.get(INTERACTION_LEASE_FD_ENV)
|
|
if raw_fd is None:
|
|
return ()
|
|
try:
|
|
fd = int(raw_fd)
|
|
except ValueError as error:
|
|
raise ValueError("interaction lease FD must be a decimal integer") from error
|
|
if fd < 0:
|
|
raise ValueError("interaction lease FD must be nonnegative")
|
|
os.fstat(fd)
|
|
return (fd,)
|
|
|
|
|
|
def _close_streams(selector, process):
|
|
for stream in (process.stdout, process.stderr):
|
|
if stream is None:
|
|
continue
|
|
try:
|
|
selector.unregister(stream)
|
|
except (KeyError, ValueError):
|
|
pass
|
|
stream.close()
|
|
|
|
|
|
def run_bounded(
|
|
command,
|
|
timeout_seconds=None,
|
|
max_capture_bytes=None,
|
|
env=None,
|
|
inherit_interaction_lease=False,
|
|
):
|
|
timeout_seconds = float(
|
|
timeout_seconds
|
|
if timeout_seconds is not None
|
|
else os.environ.get("AGENT_DESKTOP_E2E_TIMEOUT_SECONDS", DEFAULT_TIMEOUT_SECONDS)
|
|
)
|
|
max_capture_bytes = int(
|
|
max_capture_bytes
|
|
if max_capture_bytes is not None
|
|
else os.environ.get("AGENT_DESKTOP_E2E_MAX_CAPTURE_BYTES", DEFAULT_MAX_CAPTURE_BYTES)
|
|
)
|
|
if timeout_seconds <= 0 or max_capture_bytes <= 0:
|
|
raise ValueError("timeout and capture limits must be positive")
|
|
|
|
usage_before = resource.getrusage(resource.RUSAGE_CHILDREN)
|
|
started = time.perf_counter()
|
|
child_env = dict(os.environ if env is None else env)
|
|
pass_fds = _interaction_lease_fds(child_env) if inherit_interaction_lease else ()
|
|
if not inherit_interaction_lease:
|
|
child_env.pop(INTERACTION_LEASE_FD_ENV, None)
|
|
child_env.pop(INHERIT_INTERACTION_LEASE_ENV, None)
|
|
process = subprocess.Popen(
|
|
command,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
start_new_session=True,
|
|
env=child_env,
|
|
pass_fds=pass_fds,
|
|
)
|
|
selector = selectors.DefaultSelector()
|
|
selector.register(process.stdout, selectors.EVENT_READ, "stdout")
|
|
selector.register(process.stderr, selectors.EVENT_READ, "stderr")
|
|
chunks = {"stdout": [], "stderr": []}
|
|
captured = 0
|
|
deadline = started + timeout_seconds
|
|
timed_out = False
|
|
output_limited = False
|
|
termination_error = None
|
|
|
|
while selector.get_map():
|
|
remaining = deadline - time.perf_counter()
|
|
if remaining <= 0:
|
|
timed_out = True
|
|
termination_error = _merge_error(termination_error, _terminate_group(process))
|
|
_close_streams(selector, process)
|
|
break
|
|
events = selector.select(min(remaining, 0.1))
|
|
if not events:
|
|
process.poll()
|
|
continue
|
|
for key, _ in events:
|
|
data = os.read(key.fileobj.fileno(), 65536)
|
|
if not data:
|
|
selector.unregister(key.fileobj)
|
|
key.fileobj.close()
|
|
continue
|
|
available = max_capture_bytes - captured
|
|
if len(data) > available:
|
|
if available > 0:
|
|
chunks[key.data].append(data[:available])
|
|
captured += available
|
|
output_limited = True
|
|
termination_error = _merge_error(termination_error, _terminate_group(process))
|
|
_close_streams(selector, process)
|
|
break
|
|
chunks[key.data].append(data)
|
|
captured += len(data)
|
|
if output_limited:
|
|
break
|
|
|
|
selector.close()
|
|
if not timed_out and not output_limited:
|
|
remaining = max(0.0, deadline - time.perf_counter())
|
|
try:
|
|
process.wait(timeout=remaining)
|
|
except subprocess.TimeoutExpired:
|
|
timed_out = True
|
|
termination_error = _merge_error(termination_error, _terminate_group(process))
|
|
try:
|
|
process.wait(timeout=1)
|
|
except subprocess.TimeoutExpired:
|
|
termination_error = _merge_error(termination_error, _terminate_group(process))
|
|
try:
|
|
process.wait(timeout=1)
|
|
except subprocess.TimeoutExpired:
|
|
termination_error = _merge_error(
|
|
termination_error, "child could not be reaped after termination"
|
|
)
|
|
|
|
usage_after = resource.getrusage(resource.RUSAGE_CHILDREN)
|
|
cpu_before = usage_before.ru_utime + usage_before.ru_stime
|
|
cpu_after = usage_after.ru_utime + usage_after.ru_stime
|
|
if timed_out:
|
|
returncode = 124
|
|
elif output_limited:
|
|
returncode = 125
|
|
elif process.returncode is None:
|
|
returncode = 126
|
|
else:
|
|
returncode = int(process.returncode)
|
|
return BoundedResult(
|
|
args=list(command),
|
|
returncode=returncode,
|
|
stdout=b"".join(chunks["stdout"]).decode("utf-8", errors="replace"),
|
|
stderr=b"".join(chunks["stderr"]).decode("utf-8", errors="replace"),
|
|
timed_out=timed_out,
|
|
output_limited=output_limited,
|
|
termination_error=termination_error,
|
|
wall_ms=(time.perf_counter() - started) * 1000,
|
|
cpu_ms=max(0.0, (cpu_after - cpu_before) * 1000),
|
|
)
|