refactor(queue): split task queue orchestration and worker I/O
This commit is contained in:
1 parent
582f112287
commit
b8ecda9dd1
2 files changed
+568
-253
No files matched your search
@@ -13,13 +13,10 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import codecs
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import psutil
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -28,10 +25,12 @@ import uuid
|
|||||||
try:
|
try:
|
||||||
from admin_console.core.state import state
|
from admin_console.core.state import state
|
||||||
from admin_console.database.repositories.session_repository import session_repo
|
from admin_console.database.repositories.session_repository import session_repo
|
||||||
|
from admin_console.services import worker_process_io
|
||||||
from admin_console.services.media_service import media_service
|
from admin_console.services.media_service import media_service
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from apps.admin_console.core.state import state
|
from apps.admin_console.core.state import state
|
||||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||||
|
from apps.admin_console.services import worker_process_io
|
||||||
from apps.admin_console.services.media_service import media_service
|
from apps.admin_console.services.media_service import media_service
|
||||||
|
|
||||||
from artemis.config import (
|
from artemis.config import (
|
||||||
@@ -154,101 +153,12 @@ class TaskQueueService:
|
|||||||
return normalized, False
|
return normalized, False
|
||||||
return ("completed" if returncode == 0 else "failed"), True
|
return ("completed" if returncode == 0 else "failed"), True
|
||||||
|
|
||||||
@staticmethod
|
# Worker subprocess I/O plumbing lives in worker_process_io; the historical
|
||||||
def _subprocess_creation_kwargs() -> dict[str, Any]:
|
# private names stay bound here so callers and tests keep working unchanged.
|
||||||
"""Isolate task workers from the UI server's Windows console.
|
_subprocess_creation_kwargs = staticmethod(worker_process_io.subprocess_creation_kwargs)
|
||||||
|
_forward_worker_output = staticmethod(worker_process_io.forward_worker_output)
|
||||||
A new process group alone is insufficient on Windows: the worker still
|
_finish_output_forwarder = staticmethod(worker_process_io.finish_output_forwarder)
|
||||||
shares the parent's console, so a CTRL_C_EVENT generated anywhere in
|
_wait_for_worker_process = staticmethod(worker_process_io.wait_for_worker_process)
|
||||||
that console can reach the UI server. CREATE_NO_WINDOW removes that
|
|
||||||
shared console boundary.
|
|
||||||
|
|
||||||
Output is captured on every platform so it can be forwarded to the
|
|
||||||
server terminal and teed into the trace's stdout.log (the daemon itself
|
|
||||||
is often spawned with its stdio discarded, so inheriting would lose the
|
|
||||||
worker's logs entirely).
|
|
||||||
"""
|
|
||||||
kwargs: dict[str, Any] = {
|
|
||||||
"stdout": asyncio.subprocess.PIPE,
|
|
||||||
"stderr": asyncio.subprocess.STDOUT,
|
|
||||||
}
|
|
||||||
if sys.platform == "win32":
|
|
||||||
kwargs["creationflags"] = (
|
|
||||||
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
|
|
||||||
)
|
|
||||||
return kwargs
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _forward_worker_output(
|
|
||||||
stream: asyncio.StreamReader | None, log_path: str | None = None
|
|
||||||
) -> None:
|
|
||||||
"""Forward a worker's combined output without corrupting UTF-8.
|
|
||||||
|
|
||||||
When ``log_path`` is given, the output is also teed into that file so
|
|
||||||
the trace's advertised stdout.log actually exists for diagnostics.
|
|
||||||
"""
|
|
||||||
if stream is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
log_file = None
|
|
||||||
if log_path:
|
|
||||||
try:
|
|
||||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
|
||||||
log_file = open(log_path, "w", buffering=1, encoding="utf-8", errors="replace")
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[QueueWorker] Could not open worker log file '{log_path}': {exc}")
|
|
||||||
|
|
||||||
def _emit(text: str) -> None:
|
|
||||||
sys.stdout.write(text)
|
|
||||||
sys.stdout.flush()
|
|
||||||
if log_file is not None:
|
|
||||||
try:
|
|
||||||
log_file.write(text)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
# Best-effort tee into stdout.log; console output above
|
|
||||||
# already carried the text.
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
||||||
while True:
|
|
||||||
chunk = await stream.read(4096)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
text = decoder.decode(chunk)
|
|
||||||
if text:
|
|
||||||
_emit(text)
|
|
||||||
|
|
||||||
tail = decoder.decode(b"", final=True)
|
|
||||||
if tail:
|
|
||||||
_emit(tail)
|
|
||||||
finally:
|
|
||||||
if log_file is not None:
|
|
||||||
try:
|
|
||||||
log_file.close()
|
|
||||||
except OSError:
|
|
||||||
# Flush-on-close of the best-effort tee failed; nothing to do.
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _finish_output_forwarder(output_task: asyncio.Task[None] | None) -> None:
|
|
||||||
"""Drain final worker output without allowing inherited handles to stall the queue."""
|
|
||||||
if output_task is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(asyncio.shield(output_task), timeout=2.0)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
if output_task.cancelled():
|
|
||||||
return
|
|
||||||
raise
|
|
||||||
except TimeoutError:
|
|
||||||
output_task.cancel()
|
|
||||||
try:
|
|
||||||
await output_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[QueueWorker] Failed to forward detached worker output: {exc}")
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _terminate_worker_process(proc: asyncio.subprocess.Process | None) -> None:
|
async def _terminate_worker_process(proc: asyncio.subprocess.Process | None) -> None:
|
||||||
@@ -263,27 +173,6 @@ class TaskQueueService:
|
|||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _wait_for_worker_process(proc: asyncio.subprocess.Process) -> int:
|
|
||||||
"""Wait for worker process to exit, with watchdog fallback if PID was reaped externally."""
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
return await asyncio.wait_for(proc.wait(), timeout=1.0)
|
|
||||||
except TimeoutError:
|
|
||||||
pid = getattr(proc, "pid", None)
|
|
||||||
if pid:
|
|
||||||
try:
|
|
||||||
p = psutil.Process(pid)
|
|
||||||
if not p.is_running() or p.status() == psutil.STATUS_ZOMBIE:
|
|
||||||
return proc.returncode if proc.returncode is not None else -15
|
|
||||||
except (psutil.NoSuchProcess, ProcessLookupError):
|
|
||||||
return proc.returncode if proc.returncode is not None else -15
|
|
||||||
except psutil.Error:
|
|
||||||
# Transient probe failure (e.g. AccessDenied): keep waiting.
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
return proc.returncode if proc.returncode is not None else -15
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ensure_worker_running(cls):
|
def ensure_worker_running(cls):
|
||||||
"""Guarantees that the background queue worker task is active and running."""
|
"""Guarantees that the background queue worker task is active and running."""
|
||||||
@@ -414,21 +303,15 @@ class TaskQueueService:
|
|||||||
break
|
break
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _execute_task_item(cls, task_item: dict[str, Any]) -> None:
|
def _begin_task_run(
|
||||||
"""Run one queued task to completion in its own worker subprocess."""
|
cls,
|
||||||
sess_id = task_item.get("session_id")
|
task_item: dict[str, Any],
|
||||||
run_key = str(sess_id) if sess_id else uuid.uuid4().hex
|
run_key: str,
|
||||||
goal = task_item.get("goal")
|
sess_id: Any,
|
||||||
profile = task_item.get("profile", "flash")
|
goal: str,
|
||||||
proc: asyncio.subprocess.Process | None = None
|
profile: str,
|
||||||
output_task: asyncio.Task[None] | None = None
|
) -> None:
|
||||||
try:
|
"""Mark the task as running and announce the launch to subscribers."""
|
||||||
if not isinstance(goal, str) or not goal.strip():
|
|
||||||
raise ValueError("Queued task must contain a non-empty string goal.")
|
|
||||||
expected_output = task_item.get("expected_output")
|
|
||||||
enable_outputter = task_item.get("enable_outputter")
|
|
||||||
locked_app = task_item.get("locked_app_package") or task_item.get("locked_app")
|
|
||||||
app_path = task_item.get("app_path")
|
|
||||||
task_item["status"] = "running"
|
task_item["status"] = "running"
|
||||||
task_item["start_time"] = time.time()
|
task_item["start_time"] = time.time()
|
||||||
|
|
||||||
@@ -457,6 +340,22 @@ class TaskQueueService:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _build_worker_invocation(
|
||||||
|
cls,
|
||||||
|
task_item: dict[str, Any],
|
||||||
|
run_key: str,
|
||||||
|
sess_id: Any,
|
||||||
|
goal: str,
|
||||||
|
profile: str,
|
||||||
|
target: AdbTarget,
|
||||||
|
) -> tuple[list[str], dict[str, str]]:
|
||||||
|
"""Assemble the worker subprocess command line and environment."""
|
||||||
|
expected_output = task_item.get("expected_output")
|
||||||
|
enable_outputter = task_item.get("enable_outputter")
|
||||||
|
locked_app = task_item.get("locked_app_package") or task_item.get("locked_app")
|
||||||
|
app_path = task_item.get("app_path")
|
||||||
|
|
||||||
test_name = f"web_{int(time.time())}_{run_key[:8]}"
|
test_name = f"web_{int(time.time())}_{run_key[:8]}"
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
pythonpath_parts = [
|
pythonpath_parts = [
|
||||||
@@ -474,7 +373,6 @@ class TaskQueueService:
|
|||||||
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
||||||
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
|
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
|
||||||
env["ARTEMIS_TASK_WORKER"] = "1"
|
env["ARTEMIS_TASK_WORKER"] = "1"
|
||||||
target = cls._task_target(task_item)
|
|
||||||
target.endpoint.apply_to_environment(env)
|
target.endpoint.apply_to_environment(env)
|
||||||
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
|
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
|
||||||
queue_ticket = task_item.get("queue_ticket")
|
queue_ticket = task_item.get("queue_ticket")
|
||||||
@@ -505,16 +403,21 @@ class TaskQueueService:
|
|||||||
if device_serial:
|
if device_serial:
|
||||||
cmd.extend(["--device-serial", str(device_serial)])
|
cmd.extend(["--device-serial", str(device_serial)])
|
||||||
env["ADB_DEVICE_SERIAL"] = str(device_serial)
|
env["ADB_DEVICE_SERIAL"] = str(device_serial)
|
||||||
|
return cmd, env
|
||||||
|
|
||||||
print(
|
@classmethod
|
||||||
f"[QueueWorker] Starting task [{sess_id}]: '{goal}' (profile: {profile}, device: {device_serial or 'auto'}, outputter: {bool(expected_output or enable_outputter)})"
|
def _register_worker_run(
|
||||||
)
|
cls,
|
||||||
proc = await asyncio.create_subprocess_exec(
|
task_item: dict[str, Any],
|
||||||
*cmd,
|
run_key: str,
|
||||||
cwd=str(WORKSPACE_ROOT),
|
sess_id: Any,
|
||||||
env=env,
|
goal: str,
|
||||||
**cls._subprocess_creation_kwargs(),
|
profile: str,
|
||||||
)
|
target: AdbTarget,
|
||||||
|
proc: asyncio.subprocess.Process,
|
||||||
|
) -> None:
|
||||||
|
"""Record the spawned worker in shared state and hand it the device reservation."""
|
||||||
|
device_serial = task_item.get("device_serial")
|
||||||
state.current_process = proc
|
state.current_process = proc
|
||||||
task_item["pid"] = proc.pid
|
task_item["pid"] = proc.pid
|
||||||
state.active_runs[run_key] = {
|
state.active_runs[run_key] = {
|
||||||
@@ -541,7 +444,7 @@ class TaskQueueService:
|
|||||||
)
|
)
|
||||||
ingress_type = str(task_item.get("ingress", "frontend"))
|
ingress_type = str(task_item.get("ingress", "frontend"))
|
||||||
DeviceExecutionLock.transfer_reservation(
|
DeviceExecutionLock.transfer_reservation(
|
||||||
str(queue_ticket),
|
str(task_item.get("queue_ticket")),
|
||||||
proc.pid,
|
proc.pid,
|
||||||
description=f"{ingress_type} task: {goal[:120]}",
|
description=f"{ingress_type} task: {goal[:120]}",
|
||||||
device_id=device_serial or "pending",
|
device_id=device_serial or "pending",
|
||||||
@@ -549,6 +452,12 @@ class TaskQueueService:
|
|||||||
ingress=ingress_type,
|
ingress=ingress_type,
|
||||||
lock_scope=target.lock_scope,
|
lock_scope=target.lock_scope,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _start_output_forwarder(
|
||||||
|
cls, sess_id: Any, proc: asyncio.subprocess.Process
|
||||||
|
) -> asyncio.Task[None] | None:
|
||||||
|
"""Start forwarding the worker's output; returns the forwarder task, if any."""
|
||||||
# Forward the worker's combined output and tee it into the trace's
|
# Forward the worker's combined output and tee it into the trace's
|
||||||
# stdout.log so the log paths advertised by the MCP API exist.
|
# stdout.log so the log paths advertised by the MCP API exist.
|
||||||
log_path = None
|
log_path = None
|
||||||
@@ -558,10 +467,16 @@ class TaskQueueService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
log_path = None
|
log_path = None
|
||||||
if isinstance(proc.stdout, asyncio.StreamReader):
|
if isinstance(proc.stdout, asyncio.StreamReader):
|
||||||
output_task = asyncio.create_task(
|
return asyncio.create_task(
|
||||||
cls._forward_worker_output(proc.stdout, log_path)
|
cls._forward_worker_output(proc.stdout, log_path)
|
||||||
)
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _terminate_if_cancelled_during_launch(
|
||||||
|
cls, run_key: str, sess_id: Any, proc: asyncio.subprocess.Process
|
||||||
|
) -> None:
|
||||||
|
"""Terminate a worker whose task was cancelled while it was being launched."""
|
||||||
if sess_id and (
|
if sess_id and (
|
||||||
str(sess_id) in getattr(state, "cancelled_session_ids", set())
|
str(sess_id) in getattr(state, "cancelled_session_ids", set())
|
||||||
or run_key in state.manually_stopped_run_ids
|
or run_key in state.manually_stopped_run_ids
|
||||||
@@ -571,16 +486,11 @@ class TaskQueueService:
|
|||||||
)
|
)
|
||||||
await cls._terminate_worker_process(proc)
|
await cls._terminate_worker_process(proc)
|
||||||
|
|
||||||
# 3. Await subprocess completion
|
@classmethod
|
||||||
returncode = await cls._wait_for_worker_process(proc)
|
async def _persist_terminal_session_status(
|
||||||
print(f"[QueueWorker] Task [{sess_id}] exited with returncode {returncode}")
|
cls, sess_id: Any, returncode: int, manual_stop: bool
|
||||||
|
) -> str:
|
||||||
manual_stop = run_key in state.manually_stopped_run_ids or bool(
|
"""Resolve and persist the session's terminal status in the DB and trace store."""
|
||||||
sess_id and str(sess_id) in state.cancelled_session_ids
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. Perform fallback database status update and notification
|
|
||||||
if sess_id:
|
|
||||||
current_status = session_repo.get_session_status(sess_id)
|
current_status = session_repo.get_session_status(sess_id)
|
||||||
new_status, should_persist = cls._resolve_terminal_status(
|
new_status, should_persist = cls._resolve_terminal_status(
|
||||||
current_status,
|
current_status,
|
||||||
@@ -628,6 +538,11 @@ class TaskQueueService:
|
|||||||
attempt + 1,
|
attempt + 1,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
|
return new_status
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _recover_or_fail_recording(cls, sess_id: Any) -> None:
|
||||||
|
"""Attempt recovery of a not-yet-ready recording, else mark it failed."""
|
||||||
rec_info = session_repo.get_video_recording_for_session(sess_id)
|
rec_info = session_repo.get_video_recording_for_session(sess_id)
|
||||||
rec_status = (rec_info or {}).get("status")
|
rec_status = (rec_info or {}).get("status")
|
||||||
recovered_video_path = None
|
recovered_video_path = None
|
||||||
@@ -659,6 +574,16 @@ class TaskQueueService:
|
|||||||
{"session_id": sess_id, "error": recording_error},
|
{"session_id": sess_id, "error": recording_error},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _announce_session_end(
|
||||||
|
cls,
|
||||||
|
task_item: dict[str, Any],
|
||||||
|
sess_id: Any,
|
||||||
|
goal: str,
|
||||||
|
new_status: str,
|
||||||
|
manual_stop: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Broadcast session_ended and dispatch the external completion notification."""
|
||||||
state.active_connections.pop(sess_id, None)
|
state.active_connections.pop(sess_id, None)
|
||||||
cls._broadcast_event(
|
cls._broadcast_event(
|
||||||
"session_ended",
|
"session_ended",
|
||||||
@@ -689,16 +614,11 @@ class TaskQueueService:
|
|||||||
except Exception as notif_err:
|
except Exception as notif_err:
|
||||||
print(f"[QueueWorker] Notification dispatch notice: {notif_err}")
|
print(f"[QueueWorker] Notification dispatch notice: {notif_err}")
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
@classmethod
|
||||||
print(f"[QueueWorker] Task [{sess_id}] received cancellation signal.")
|
def _release_run_slot(
|
||||||
if proc is not None and proc.returncode is None:
|
cls, sess_id: Any, run_key: str, proc: asyncio.subprocess.Process | None
|
||||||
await cls._terminate_worker_process(proc)
|
) -> None:
|
||||||
raise
|
"""Clean up the finished task and release this run's scheduling slot."""
|
||||||
except Exception as e:
|
|
||||||
print(f"[QueueWorker] Unexpected error executing task [{sess_id}]: {e}")
|
|
||||||
finally:
|
|
||||||
await cls._finish_output_forwarder(output_task)
|
|
||||||
# 5. Clean up the finished task and release this run's scheduling slot
|
|
||||||
if sess_id:
|
if sess_id:
|
||||||
cls._remove_task(sess_id)
|
cls._remove_task(sess_id)
|
||||||
state.cancelled_session_ids.discard(str(sess_id))
|
state.cancelled_session_ids.discard(str(sess_id))
|
||||||
@@ -717,26 +637,80 @@ class TaskQueueService:
|
|||||||
state.wake_event.set()
|
state.wake_event.set()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def enqueue_tasks(
|
async def _execute_task_item(cls, task_item: dict[str, Any]) -> None:
|
||||||
|
"""Run one queued task to completion in its own worker subprocess."""
|
||||||
|
sess_id = task_item.get("session_id")
|
||||||
|
run_key = str(sess_id) if sess_id else uuid.uuid4().hex
|
||||||
|
goal = task_item.get("goal")
|
||||||
|
profile = task_item.get("profile", "flash")
|
||||||
|
proc: asyncio.subprocess.Process | None = None
|
||||||
|
output_task: asyncio.Task[None] | None = None
|
||||||
|
try:
|
||||||
|
if not isinstance(goal, str) or not goal.strip():
|
||||||
|
raise ValueError("Queued task must contain a non-empty string goal.")
|
||||||
|
cls._begin_task_run(task_item, run_key, sess_id, goal, profile)
|
||||||
|
|
||||||
|
target = cls._task_target(task_item)
|
||||||
|
cmd, env = cls._build_worker_invocation(
|
||||||
|
task_item, run_key, sess_id, goal, profile, target
|
||||||
|
)
|
||||||
|
|
||||||
|
device_serial = task_item.get("device_serial")
|
||||||
|
print(
|
||||||
|
f"[QueueWorker] Starting task [{sess_id}]: '{goal}' (profile: {profile}, device: {device_serial or 'auto'}, outputter: {bool(task_item.get('expected_output') or task_item.get('enable_outputter'))})"
|
||||||
|
)
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
cwd=str(WORKSPACE_ROOT),
|
||||||
|
env=env,
|
||||||
|
**cls._subprocess_creation_kwargs(),
|
||||||
|
)
|
||||||
|
cls._register_worker_run(
|
||||||
|
task_item, run_key, sess_id, goal, profile, target, proc
|
||||||
|
)
|
||||||
|
output_task = cls._start_output_forwarder(sess_id, proc)
|
||||||
|
await cls._terminate_if_cancelled_during_launch(run_key, sess_id, proc)
|
||||||
|
|
||||||
|
# 3. Await subprocess completion
|
||||||
|
returncode = await cls._wait_for_worker_process(proc)
|
||||||
|
print(f"[QueueWorker] Task [{sess_id}] exited with returncode {returncode}")
|
||||||
|
|
||||||
|
manual_stop = run_key in state.manually_stopped_run_ids or bool(
|
||||||
|
sess_id and str(sess_id) in state.cancelled_session_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Perform fallback database status update and notification
|
||||||
|
if sess_id:
|
||||||
|
new_status = await cls._persist_terminal_session_status(
|
||||||
|
sess_id, returncode, manual_stop
|
||||||
|
)
|
||||||
|
await cls._recover_or_fail_recording(sess_id)
|
||||||
|
cls._announce_session_end(
|
||||||
|
task_item, sess_id, goal, new_status, manual_stop
|
||||||
|
)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
print(f"[QueueWorker] Task [{sess_id}] received cancellation signal.")
|
||||||
|
if proc is not None and proc.returncode is None:
|
||||||
|
await cls._terminate_worker_process(proc)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[QueueWorker] Unexpected error executing task [{sess_id}]: {e}")
|
||||||
|
finally:
|
||||||
|
await cls._finish_output_forwarder(output_task)
|
||||||
|
# 5. Clean up the finished task and release this run's scheduling slot
|
||||||
|
cls._release_run_slot(sess_id, run_key, proc)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _find_duplicate_submission(
|
||||||
cls,
|
cls,
|
||||||
goals: list[str],
|
goals: list[str],
|
||||||
profile: str = "flash",
|
session_id: str | None,
|
||||||
expected_output: str | None = None,
|
device_serial: str | None,
|
||||||
enable_outputter: bool | None = None,
|
endpoint: AdbEndpoint,
|
||||||
locked_app_package: str | None = None,
|
now: float,
|
||||||
app_path: str | None = None,
|
) -> dict[str, Any] | None:
|
||||||
device_serial: str | None = None,
|
"""Return the short-circuit response for a duplicate submission, if any."""
|
||||||
ingress: str = "frontend",
|
|
||||||
session_id: str | None = None,
|
|
||||||
conversation_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Enqueues one or more goals and wakes up the background worker."""
|
|
||||||
cls.ensure_worker_running()
|
|
||||||
|
|
||||||
enqueued_tasks = []
|
|
||||||
now = time.time()
|
|
||||||
endpoint = current_adb_endpoint()
|
|
||||||
|
|
||||||
# 1. Deduplication by session_id: if session_id is already running or queued, do not re-enqueue
|
# 1. Deduplication by session_id: if session_id is already running or queued, do not re-enqueue
|
||||||
if session_id and len(goals) == 1:
|
if session_id and len(goals) == 1:
|
||||||
target_sid = str(session_id)
|
target_sid = str(session_id)
|
||||||
@@ -786,7 +760,13 @@ class TaskQueueService:
|
|||||||
"enqueued_count": 0,
|
"enqueued_count": 0,
|
||||||
"total_queued": len(state.queue_tasks),
|
"total_queued": len(state.queue_tasks),
|
||||||
}
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _reject_unavailable_device(
|
||||||
|
cls, device_serial: str | None
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Return the rejection response for an unattached explicit serial, if any."""
|
||||||
# Strict device binding: reject an explicitly requested serial that is not
|
# Strict device binding: reject an explicitly requested serial that is not
|
||||||
# attached and authorized, instead of silently running on another device.
|
# attached and authorized, instead of silently running on another device.
|
||||||
# The shared validator fails open on an indeterminate/empty enumeration so
|
# The shared validator fails open on an indeterminate/empty enumeration so
|
||||||
@@ -806,9 +786,27 @@ class TaskQueueService:
|
|||||||
"enqueued_count": 0,
|
"enqueued_count": 0,
|
||||||
"total_queued": len(state.queue_tasks),
|
"total_queued": len(state.queue_tasks),
|
||||||
}
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
for i, goal in enumerate(goals):
|
@classmethod
|
||||||
sess_id = (session_id if (session_id and len(goals) == 1) else str(uuid.uuid4()))
|
def _create_queue_item(
|
||||||
|
cls,
|
||||||
|
goal: str,
|
||||||
|
index: int,
|
||||||
|
now: float,
|
||||||
|
endpoint: AdbEndpoint,
|
||||||
|
single_session_id: str | None,
|
||||||
|
profile: str,
|
||||||
|
expected_output: str | None,
|
||||||
|
enable_outputter: bool | None,
|
||||||
|
locked_app_package: str | None,
|
||||||
|
app_path: str | None,
|
||||||
|
device_serial: str | None,
|
||||||
|
ingress: str,
|
||||||
|
conversation_id: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Reserve a device slot and build one pending queue item for a goal."""
|
||||||
|
sess_id = single_session_id if single_session_id else str(uuid.uuid4())
|
||||||
assigned_serial = device_serial
|
assigned_serial = device_serial
|
||||||
if not assigned_serial:
|
if not assigned_serial:
|
||||||
try:
|
try:
|
||||||
@@ -825,7 +823,7 @@ class TaskQueueService:
|
|||||||
ingress=ingress,
|
ingress=ingress,
|
||||||
lock_scope=endpoint.identity,
|
lock_scope=endpoint.identity,
|
||||||
)
|
)
|
||||||
task_item = {
|
return {
|
||||||
"session_id": sess_id,
|
"session_id": sess_id,
|
||||||
"goal": goal,
|
"goal": goal,
|
||||||
"profile": profile or "flash",
|
"profile": profile or "flash",
|
||||||
@@ -839,13 +837,62 @@ class TaskQueueService:
|
|||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"queue_ticket": queue_ticket,
|
"queue_ticket": queue_ticket,
|
||||||
"created_at": now + i * 0.001,
|
"created_at": now + index * 0.001,
|
||||||
"start_time": now + i * 0.001,
|
"start_time": now + index * 0.001,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def enqueue_tasks(
|
||||||
|
cls,
|
||||||
|
goals: list[str],
|
||||||
|
profile: str = "flash",
|
||||||
|
expected_output: str | None = None,
|
||||||
|
enable_outputter: bool | None = None,
|
||||||
|
locked_app_package: str | None = None,
|
||||||
|
app_path: str | None = None,
|
||||||
|
device_serial: str | None = None,
|
||||||
|
ingress: str = "frontend",
|
||||||
|
session_id: str | None = None,
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Enqueues one or more goals and wakes up the background worker."""
|
||||||
|
cls.ensure_worker_running()
|
||||||
|
|
||||||
|
enqueued_tasks = []
|
||||||
|
now = time.time()
|
||||||
|
endpoint = current_adb_endpoint()
|
||||||
|
|
||||||
|
duplicate_response = cls._find_duplicate_submission(
|
||||||
|
goals, session_id, device_serial, endpoint, now
|
||||||
|
)
|
||||||
|
if duplicate_response is not None:
|
||||||
|
return duplicate_response
|
||||||
|
|
||||||
|
rejection_response = await cls._reject_unavailable_device(device_serial)
|
||||||
|
if rejection_response is not None:
|
||||||
|
return rejection_response
|
||||||
|
|
||||||
|
single_session_id = session_id if (session_id and len(goals) == 1) else None
|
||||||
|
for i, goal in enumerate(goals):
|
||||||
|
task_item = cls._create_queue_item(
|
||||||
|
goal,
|
||||||
|
i,
|
||||||
|
now,
|
||||||
|
endpoint,
|
||||||
|
single_session_id,
|
||||||
|
profile,
|
||||||
|
expected_output,
|
||||||
|
enable_outputter,
|
||||||
|
locked_app_package,
|
||||||
|
app_path,
|
||||||
|
device_serial,
|
||||||
|
ingress,
|
||||||
|
conversation_id,
|
||||||
|
)
|
||||||
state.queue_items.append(task_item)
|
state.queue_items.append(task_item)
|
||||||
enqueued_tasks.append(task_item)
|
enqueued_tasks.append(task_item)
|
||||||
cls._broadcast_startup_progress(
|
cls._broadcast_startup_progress(
|
||||||
sess_id, "queued", "Task received and queued"
|
task_item["session_id"], "queued", "Task received and queued"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wake worker immediately
|
# Wake worker immediately
|
||||||
@@ -858,36 +905,20 @@ class TaskQueueService:
|
|||||||
"total_queued": len(state.queue_tasks),
|
"total_queued": len(state.queue_tasks),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clear_pause_file() -> None:
|
||||||
|
"""Remove a leftover pause marker after a stop request."""
|
||||||
|
if PAUSE_FILE.exists():
|
||||||
|
try:
|
||||||
|
PAUSE_FILE.unlink()
|
||||||
|
except OSError:
|
||||||
|
# Best-effort cleanup of the pause marker; a leftover file
|
||||||
|
# only pauses until the next resume request.
|
||||||
|
pass
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def stop_tasks(
|
def _terminate_all_device_owners(cls) -> None:
|
||||||
cls,
|
"""Terminate every device lock owner and persist each cancellation."""
|
||||||
clear_all: bool = False,
|
|
||||||
session_id: str | None = None,
|
|
||||||
device_id: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Stop the active task controlling a mobile device or all tasks.
|
|
||||||
|
|
||||||
The active lease is shared by frontend, MCP, CLI, SDK, and other UI
|
|
||||||
processes across all connected devices.
|
|
||||||
- If ``session_id`` or ``device_id`` is specified, only the corresponding
|
|
||||||
running or queued task is cancelled.
|
|
||||||
- If ``clear_all`` is requested, all active device owners are terminated
|
|
||||||
and pending queue submissions are cleared.
|
|
||||||
- If no specific task is specified and ``clear_all`` is False, stops the
|
|
||||||
currently active task in single-device mode for backward compatibility.
|
|
||||||
"""
|
|
||||||
stopped = False
|
|
||||||
target_sid = str(session_id).strip() if session_id else None
|
|
||||||
target_device = str(device_id).strip() if device_id else None
|
|
||||||
|
|
||||||
if clear_all:
|
|
||||||
# 1. Cancel local queue reservations
|
|
||||||
for item in state.queue_items:
|
|
||||||
if isinstance(item, dict) and item.get("status") != "running":
|
|
||||||
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
|
||||||
state.clear_queue()
|
|
||||||
|
|
||||||
# 2. Terminate all active owners across all devices
|
|
||||||
active_owners: dict[str, Any] = {}
|
active_owners: dict[str, Any] = {}
|
||||||
try:
|
try:
|
||||||
active_owners = DeviceExecutionLock.get_active_owners()
|
active_owners = DeviceExecutionLock.get_active_owners()
|
||||||
@@ -929,6 +960,9 @@ class TaskQueueService:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _kill_all_local_runs(cls) -> None:
|
||||||
|
"""Mark every locally-managed run manually stopped and kill its process."""
|
||||||
for run_key, run in list(state.active_runs.items()):
|
for run_key, run in list(state.active_runs.items()):
|
||||||
# Mark each run individually: the global "stopped manually"
|
# Mark each run individually: the global "stopped manually"
|
||||||
# boolean was shared process-wide and polluted the terminal
|
# boolean was shared process-wide and polluted the terminal
|
||||||
@@ -955,31 +989,38 @@ class TaskQueueService:
|
|||||||
pass
|
pass
|
||||||
state.current_process = None
|
state.current_process = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _stop_all_tasks(cls) -> bool:
|
||||||
|
"""Terminate all active device owners and clear pending queue submissions."""
|
||||||
|
# 1. Cancel local queue reservations
|
||||||
|
for item in state.queue_items:
|
||||||
|
if isinstance(item, dict) and item.get("status") != "running":
|
||||||
|
DeviceExecutionLock.cancel_reservation(item.get("queue_ticket"))
|
||||||
|
state.clear_queue()
|
||||||
|
|
||||||
|
# 2. Terminate all active owners across all devices
|
||||||
|
cls._terminate_all_device_owners()
|
||||||
|
cls._kill_all_local_runs()
|
||||||
|
|
||||||
state.active_connections.clear()
|
state.active_connections.clear()
|
||||||
state.active_session_id = None
|
state.active_session_id = None
|
||||||
state.current_goal = None
|
state.current_goal = None
|
||||||
state.current_profile = None
|
state.current_profile = None
|
||||||
|
|
||||||
if PAUSE_FILE.exists():
|
cls._clear_pause_file()
|
||||||
try:
|
|
||||||
PAUSE_FILE.unlink()
|
|
||||||
except OSError:
|
|
||||||
# Best-effort cleanup of the pause marker; a leftover file
|
|
||||||
# only pauses until the next resume request.
|
|
||||||
pass
|
|
||||||
|
|
||||||
cls.ensure_worker_running()
|
cls.ensure_worker_running()
|
||||||
state.wake_event.set()
|
state.wake_event.set()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Stop a specific task (or default single-device active task)
|
@classmethod
|
||||||
active_owners = {}
|
def _resolve_stop_owner(
|
||||||
try:
|
cls,
|
||||||
active_owners = DeviceExecutionLock.get_active_owners()
|
active_owners: dict[str, Any],
|
||||||
except OSError as exc:
|
target_sid: str | None,
|
||||||
# Unreadable lock dir: the local-process fallbacks below still apply.
|
target_device: str | None,
|
||||||
print(f"[stop_tasks] Could not enumerate device owners: {exc}")
|
) -> Any:
|
||||||
|
"""Resolve the device lock owner targeted by this stop request, if any."""
|
||||||
owner = None
|
owner = None
|
||||||
if target_sid:
|
if target_sid:
|
||||||
for dev_owner in active_owners.values():
|
for dev_owner in active_owners.values():
|
||||||
@@ -1003,12 +1044,17 @@ class TaskQueueService:
|
|||||||
or (fallback_owner.session_id and str(fallback_owner.session_id) == target_sid)
|
or (fallback_owner.session_id and str(fallback_owner.session_id) == target_sid)
|
||||||
):
|
):
|
||||||
owner = fallback_owner
|
owner = fallback_owner
|
||||||
|
return owner
|
||||||
|
|
||||||
owner_record_exists = DeviceExecutionLock.has_owner_record(target_device)
|
@classmethod
|
||||||
owner_pid = owner.pid if owner else None
|
def _resolve_local_run(
|
||||||
|
cls, target_sid: str | None, target_device: str | None
|
||||||
|
) -> tuple[str | None, dict[str, Any] | None, Any]:
|
||||||
|
"""Resolve the locally-managed run for this target.
|
||||||
|
|
||||||
# Resolve the locally-managed run for this target (concurrent scheduling
|
Concurrent scheduling keeps one subprocess per run in
|
||||||
# keeps one subprocess per run in state.active_runs).
|
state.active_runs. Returns ``(local_run_key, local_run, local_proc)``.
|
||||||
|
"""
|
||||||
local_run = None
|
local_run = None
|
||||||
local_run_key: str | None = None
|
local_run_key: str | None = None
|
||||||
if target_sid:
|
if target_sid:
|
||||||
@@ -1047,9 +1093,11 @@ class TaskQueueService:
|
|||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
local_pid = getattr(local_proc, "pid", None)
|
return local_run_key, local_run, local_proc
|
||||||
is_local_owner = bool(owner_pid and local_pid and owner_pid == local_pid)
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _find_target_queue_item(cls, target_sid: str | None) -> dict[str, Any] | None:
|
||||||
|
"""Find the running (preferred) or pending queue item for this target."""
|
||||||
running_item = next(
|
running_item = next(
|
||||||
(
|
(
|
||||||
item
|
item
|
||||||
@@ -1060,7 +1108,7 @@ class TaskQueueService:
|
|||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
local_item = running_item or next(
|
return running_item or next(
|
||||||
(
|
(
|
||||||
item
|
item
|
||||||
for item in state.queue_items
|
for item in state.queue_items
|
||||||
@@ -1070,10 +1118,21 @@ class TaskQueueService:
|
|||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _resolve_stopped_session_id(
|
||||||
|
cls,
|
||||||
|
owner: Any,
|
||||||
|
target_sid: str | None,
|
||||||
|
local_run_key: str | None,
|
||||||
|
is_local_owner: bool,
|
||||||
|
local_item: dict[str, Any] | None,
|
||||||
|
) -> Any:
|
||||||
|
"""Attribute the stop request to a session id, if one can be resolved."""
|
||||||
# active_session_id mirrors the most recently launched run, so with
|
# active_session_id mirrors the most recently launched run, so with
|
||||||
# concurrent runs the resolved local run key (== its session id) must
|
# concurrent runs the resolved local run key (== its session id) must
|
||||||
# take precedence to avoid attributing the stop to an unrelated run.
|
# take precedence to avoid attributing the stop to an unrelated run.
|
||||||
stopped_session_id = (
|
return (
|
||||||
owner.session_id
|
owner.session_id
|
||||||
if owner and owner.session_id
|
if owner and owner.session_id
|
||||||
else target_sid
|
else target_sid
|
||||||
@@ -1087,6 +1146,25 @@ class TaskQueueService:
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _terminate_stop_target(
|
||||||
|
cls,
|
||||||
|
owner: Any,
|
||||||
|
owner_record_exists: bool,
|
||||||
|
target_sid: str | None,
|
||||||
|
local_run: dict[str, Any] | None,
|
||||||
|
local_run_key: str | None,
|
||||||
|
local_proc: Any,
|
||||||
|
local_pid: Any,
|
||||||
|
local_item: dict[str, Any] | None,
|
||||||
|
is_local_owner: bool,
|
||||||
|
) -> tuple[bool, bool, bool] | None:
|
||||||
|
"""Terminate the resolved stop target.
|
||||||
|
|
||||||
|
Returns ``(stopped, is_local_owner, reservation_cancelled)``, or
|
||||||
|
``None`` when the caller must refuse the stop outright.
|
||||||
|
"""
|
||||||
|
stopped = False
|
||||||
reservation_cancelled = False
|
reservation_cancelled = False
|
||||||
if owner and DeviceExecutionLock.is_active_owner(owner):
|
if owner and DeviceExecutionLock.is_active_owner(owner):
|
||||||
stopped = process_supervisor.terminate_tree_verified(
|
stopped = process_supervisor.terminate_tree_verified(
|
||||||
@@ -1097,7 +1175,7 @@ class TaskQueueService:
|
|||||||
elif owner is None and owner_record_exists and not target_sid:
|
elif owner is None and owner_record_exists and not target_sid:
|
||||||
# Never fall back to a frontend PID while another process has an
|
# Never fall back to a frontend PID while another process has an
|
||||||
# owner record that is still being published or cannot be parsed.
|
# owner record that is still being published or cannot be parsed.
|
||||||
return False
|
return None
|
||||||
elif owner is None and local_proc is not None and (
|
elif owner is None and local_proc is not None and (
|
||||||
local_run is not None
|
local_run is not None
|
||||||
or not target_sid
|
or not target_sid
|
||||||
@@ -1156,10 +1234,19 @@ class TaskQueueService:
|
|||||||
)
|
)
|
||||||
DeviceExecutionLock.cleanup_stale_locks()
|
DeviceExecutionLock.cleanup_stale_locks()
|
||||||
stopped = True
|
stopped = True
|
||||||
|
return stopped, is_local_owner, reservation_cancelled
|
||||||
|
|
||||||
if not stopped and not target_sid:
|
@classmethod
|
||||||
return False
|
def _finalize_targeted_stop(
|
||||||
|
cls,
|
||||||
|
stopped_session_id: Any,
|
||||||
|
owner: Any,
|
||||||
|
owner_pid: Any,
|
||||||
|
is_local_owner: bool,
|
||||||
|
local_run_key: str | None,
|
||||||
|
local_proc: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Propagate the stop into shared state, the DB, and event subscribers."""
|
||||||
if is_local_owner:
|
if is_local_owner:
|
||||||
if stopped_session_id:
|
if stopped_session_id:
|
||||||
# Per-session cancellation: the run's own finalizer resolves the
|
# Per-session cancellation: the run's own finalizer resolves the
|
||||||
@@ -1207,7 +1294,13 @@ class TaskQueueService:
|
|||||||
state.current_goal = None
|
state.current_goal = None
|
||||||
state.current_profile = None
|
state.current_profile = None
|
||||||
|
|
||||||
if stopped_session_id:
|
@classmethod
|
||||||
|
def _remove_stopped_queue_item(
|
||||||
|
cls, stopped_session_id: Any, reservation_cancelled: bool
|
||||||
|
) -> None:
|
||||||
|
"""Drop the stopped session's queue item and cancel its reservation."""
|
||||||
|
if not stopped_session_id:
|
||||||
|
return
|
||||||
stopped_item = next(
|
stopped_item = next(
|
||||||
(
|
(
|
||||||
item
|
item
|
||||||
@@ -1228,18 +1321,94 @@ class TaskQueueService:
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
if PAUSE_FILE.exists():
|
@classmethod
|
||||||
|
def _stop_targeted_task(
|
||||||
|
cls, target_sid: str | None, target_device: str | None
|
||||||
|
) -> bool:
|
||||||
|
"""Stop a specific task (or default single-device active task)."""
|
||||||
|
active_owners = {}
|
||||||
try:
|
try:
|
||||||
PAUSE_FILE.unlink()
|
active_owners = DeviceExecutionLock.get_active_owners()
|
||||||
except OSError:
|
except OSError as exc:
|
||||||
# Best-effort cleanup of the pause marker; a leftover file
|
# Unreadable lock dir: the local-process fallbacks below still apply.
|
||||||
# only pauses until the next resume request.
|
print(f"[stop_tasks] Could not enumerate device owners: {exc}")
|
||||||
pass
|
|
||||||
|
owner = cls._resolve_stop_owner(active_owners, target_sid, target_device)
|
||||||
|
|
||||||
|
owner_record_exists = DeviceExecutionLock.has_owner_record(target_device)
|
||||||
|
owner_pid = owner.pid if owner else None
|
||||||
|
|
||||||
|
local_run_key, local_run, local_proc = cls._resolve_local_run(
|
||||||
|
target_sid, target_device
|
||||||
|
)
|
||||||
|
local_pid = getattr(local_proc, "pid", None)
|
||||||
|
is_local_owner = bool(owner_pid and local_pid and owner_pid == local_pid)
|
||||||
|
|
||||||
|
local_item = cls._find_target_queue_item(target_sid)
|
||||||
|
stopped_session_id = cls._resolve_stopped_session_id(
|
||||||
|
owner, target_sid, local_run_key, is_local_owner, local_item
|
||||||
|
)
|
||||||
|
|
||||||
|
outcome = cls._terminate_stop_target(
|
||||||
|
owner,
|
||||||
|
owner_record_exists,
|
||||||
|
target_sid,
|
||||||
|
local_run,
|
||||||
|
local_run_key,
|
||||||
|
local_proc,
|
||||||
|
local_pid,
|
||||||
|
local_item,
|
||||||
|
is_local_owner,
|
||||||
|
)
|
||||||
|
if outcome is None:
|
||||||
|
return False
|
||||||
|
stopped, is_local_owner, reservation_cancelled = outcome
|
||||||
|
|
||||||
|
if not stopped and not target_sid:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cls._finalize_targeted_stop(
|
||||||
|
stopped_session_id,
|
||||||
|
owner,
|
||||||
|
owner_pid,
|
||||||
|
is_local_owner,
|
||||||
|
local_run_key,
|
||||||
|
local_proc,
|
||||||
|
)
|
||||||
|
cls._remove_stopped_queue_item(stopped_session_id, reservation_cancelled)
|
||||||
|
|
||||||
|
cls._clear_pause_file()
|
||||||
|
|
||||||
cls.ensure_worker_running()
|
cls.ensure_worker_running()
|
||||||
state.wake_event.set()
|
state.wake_event.set()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def stop_tasks(
|
||||||
|
cls,
|
||||||
|
clear_all: bool = False,
|
||||||
|
session_id: str | None = None,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Stop the active task controlling a mobile device or all tasks.
|
||||||
|
|
||||||
|
The active lease is shared by frontend, MCP, CLI, SDK, and other UI
|
||||||
|
processes across all connected devices.
|
||||||
|
- If ``session_id`` or ``device_id`` is specified, only the corresponding
|
||||||
|
running or queued task is cancelled.
|
||||||
|
- If ``clear_all`` is requested, all active device owners are terminated
|
||||||
|
and pending queue submissions are cleared.
|
||||||
|
- If no specific task is specified and ``clear_all`` is False, stops the
|
||||||
|
currently active task in single-device mode for backward compatibility.
|
||||||
|
"""
|
||||||
|
target_sid = str(session_id).strip() if session_id else None
|
||||||
|
target_device = str(device_id).strip() if device_id else None
|
||||||
|
|
||||||
|
if clear_all:
|
||||||
|
return cls._stop_all_tasks()
|
||||||
|
|
||||||
|
return cls._stop_targeted_task(target_sid, target_device)
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def resume_task(cls) -> bool:
|
def resume_task(cls) -> bool:
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Copyright 2026 Google LLC
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Low-level worker subprocess I/O plumbing for the task queue.
|
||||||
|
|
||||||
|
These helpers are deliberately free of admin-console state: they deal only
|
||||||
|
with spawning options, output forwarding, and process-exit observation for a
|
||||||
|
single worker subprocess. TaskQueueService exposes them as its private
|
||||||
|
static methods so existing callers and tests keep working unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import codecs
|
||||||
|
import os
|
||||||
|
import psutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def subprocess_creation_kwargs() -> dict[str, Any]:
|
||||||
|
"""Isolate task workers from the UI server's Windows console.
|
||||||
|
|
||||||
|
A new process group alone is insufficient on Windows: the worker still
|
||||||
|
shares the parent's console, so a CTRL_C_EVENT generated anywhere in
|
||||||
|
that console can reach the UI server. CREATE_NO_WINDOW removes that
|
||||||
|
shared console boundary.
|
||||||
|
|
||||||
|
Output is captured on every platform so it can be forwarded to the
|
||||||
|
server terminal and teed into the trace's stdout.log (the daemon itself
|
||||||
|
is often spawned with its stdio discarded, so inheriting would lose the
|
||||||
|
worker's logs entirely).
|
||||||
|
"""
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"stdout": asyncio.subprocess.PIPE,
|
||||||
|
"stderr": asyncio.subprocess.STDOUT,
|
||||||
|
}
|
||||||
|
if sys.platform == "win32":
|
||||||
|
kwargs["creationflags"] = (
|
||||||
|
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
|
||||||
|
)
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
async def forward_worker_output(
|
||||||
|
stream: asyncio.StreamReader | None, log_path: str | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Forward a worker's combined output without corrupting UTF-8.
|
||||||
|
|
||||||
|
When ``log_path`` is given, the output is also teed into that file so
|
||||||
|
the trace's advertised stdout.log actually exists for diagnostics.
|
||||||
|
"""
|
||||||
|
if stream is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
log_file = None
|
||||||
|
if log_path:
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||||
|
log_file = open(log_path, "w", buffering=1, encoding="utf-8", errors="replace")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[QueueWorker] Could not open worker log file '{log_path}': {exc}")
|
||||||
|
|
||||||
|
def _emit(text: str) -> None:
|
||||||
|
sys.stdout.write(text)
|
||||||
|
sys.stdout.flush()
|
||||||
|
if log_file is not None:
|
||||||
|
try:
|
||||||
|
log_file.write(text)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
# Best-effort tee into stdout.log; console output above
|
||||||
|
# already carried the text.
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||||
|
while True:
|
||||||
|
chunk = await stream.read(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
text = decoder.decode(chunk)
|
||||||
|
if text:
|
||||||
|
_emit(text)
|
||||||
|
|
||||||
|
tail = decoder.decode(b"", final=True)
|
||||||
|
if tail:
|
||||||
|
_emit(tail)
|
||||||
|
finally:
|
||||||
|
if log_file is not None:
|
||||||
|
try:
|
||||||
|
log_file.close()
|
||||||
|
except OSError:
|
||||||
|
# Flush-on-close of the best-effort tee failed; nothing to do.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def finish_output_forwarder(output_task: asyncio.Task[None] | None) -> None:
|
||||||
|
"""Drain final worker output without allowing inherited handles to stall the queue."""
|
||||||
|
if output_task is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(asyncio.shield(output_task), timeout=2.0)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if output_task.cancelled():
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
except TimeoutError:
|
||||||
|
output_task.cancel()
|
||||||
|
try:
|
||||||
|
await output_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[QueueWorker] Failed to forward detached worker output: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_worker_process(proc: asyncio.subprocess.Process) -> int:
|
||||||
|
"""Wait for worker process to exit, with watchdog fallback if PID was reaped externally."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(proc.wait(), timeout=1.0)
|
||||||
|
except TimeoutError:
|
||||||
|
pid = getattr(proc, "pid", None)
|
||||||
|
if pid:
|
||||||
|
try:
|
||||||
|
p = psutil.Process(pid)
|
||||||
|
if not p.is_running() or p.status() == psutil.STATUS_ZOMBIE:
|
||||||
|
return proc.returncode if proc.returncode is not None else -15
|
||||||
|
except (psutil.NoSuchProcess, ProcessLookupError):
|
||||||
|
return proc.returncode if proc.returncode is not None else -15
|
||||||
|
except psutil.Error:
|
||||||
|
# Transient probe failure (e.g. AccessDenied): keep waiting.
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return proc.returncode if proc.returncode is not None else -15
|
||||||
Reference in new issue
Block a user