fix(runtime): recover awake ownership and batch model failures

This commit is contained in:
chenmingxuan committed 2026-10-28 11:50:05 +08:00
1 parent 6b2c0f199d
commit baf069854b
17 files changed
+477 -32

No files matched your search

+19 -6
View File
@@ -96,6 +96,7 @@ from artemis.services.llm import (
get_openai_llm,
get_llm,
invoke_llm_with_timeout_message,
with_fallback,
)
from artemis.tools.history import history_tool_declarations
from artemis.tools.android_files import android_file
@@ -1506,20 +1507,32 @@ class FlashRunner:
turn_base = len(messages) - 1
current_tools = report_only_tools if is_final else self._get_tools()
async def invoke_turn():
if using_tool_fallback:
return await self._invoke_model(fallback_llm, current_tools, messages)
async def invoke_fallback():
nonlocal fallback_llm
if fallback_llm is None:
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
return await self._invoke_model(fallback_llm, current_tools, messages)
return await with_fallback(
lambda: self._invoke_model(llm, current_tools, messages),
invoke_fallback,
none_should_fallback=False,
)
if isinstance(control, TaskControl):
interrupted, response = await control.run_interruptibly(
self._invoke_model(
fallback_llm if using_tool_fallback else llm, current_tools, messages
),
invoke_turn(),
on_pause=getattr(self.ctx, "on_task_pause", None),
on_resume=getattr(self.ctx, "on_task_resume", None),
)
else:
interrupted, response = (
False,
await self._invoke_model(
fallback_llm if using_tool_fallback else llm, current_tools, messages
),
await invoke_turn(),
)
if interrupted or (
+13 -3
View File
@@ -35,7 +35,7 @@ from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
from artemis.llm.usage import usage_from_message
from artemis.llm.structured import content_to_text
from artemis.runtime.task_control import TaskControl, has_pending_instruction
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message, with_fallback
from artemis.tools.command_tool import (
analyze_task_output_wrapper,
get_adb_task_registry,
@@ -638,9 +638,19 @@ class OperatorNode:
and t.name not in {"plugin_load", "plugin_read_file", "plugin_read_result"}
]
traced_tools.extend(plugins.langchain_tools())
bound_llm = active_llm.bind_tools(tools=traced_tools)
async def invoke(model):
bound = model.bind_tools(tools=traced_tools)
return await invoke_llm_with_timeout_message(acomplete(bound, current_messages))
interrupted, response = await self._await_or_pause(
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
invoke(active_llm)
if using_tool_fallback
else with_fallback(
lambda: invoke(base_llm),
lambda: invoke(get_llm(ctx=self.ctx, name="operator", use_fallback=True)),
none_should_fallback=False,
)
)
if interrupted:
return None, None, None, False
+8
View File
@@ -164,6 +164,7 @@ async def execute_task(
config.with_graph_config_callbacks(graph_config_callbacks)
agent: Agent | None = None
task_started = False
try:
agent = Agent(config=config.build(), session_id=effective_sid)
await agent.init(
@@ -196,7 +197,14 @@ async def execute_task(
request.plugins = plugins
elif plugin_choices is not None:
request.plugins = TypeAdapter(list[PluginSelection]).validate_json(plugin_choices)
task_started = True
await agent.run_task(request=request)
except Exception as exc:
if not task_started and effective_sid:
from artemis.runtime.task_result import save_failure_result
save_failure_result(str(effective_sid), str(exc), code="initialization_failed")
raise
finally:
if agent is not None:
await agent.clean()
+17 -2
View File
@@ -101,7 +101,10 @@ def _configure_usb_stay_awake(device_id: str) -> str | None:
# Migration cleanup belongs to the long-lived host. A queued task worker
# must not wait on another process's optional legacy wake-lock cleanup.
if os.environ.get("ARTEMIS_TASK_WORKER") != "1":
ScreenAwakeLease(device_id).cleanup_unowned_references()
try:
ScreenAwakeLease(device_id).cleanup_unowned_references()
except OSError as exc:
logger.warning(f"Could not prepare optional legacy awake cleanup on {device_id}: {exc}")
commands = (
(
@@ -209,6 +212,7 @@ class ScreenAwakeService:
self._monitor_thread: threading.Thread | None = None
self._shutdown_requested = False
self._owners: dict[str, FileLock] = {}
self._ownership_warnings: set[str] = set()
def start(self, device_ids: Iterable[str] | None = None) -> dict[str, str | None]:
if self._shutdown_requested:
@@ -241,13 +245,24 @@ class ScreenAwakeService:
existing = self._strategies.get(device_id)
if existing is not None:
return existing
owner = _owner_lock(device_id)
try:
owner = _owner_lock(device_id)
owner.acquire(timeout=0)
except Timeout:
# Do not cache a follower as owner: discovery can take over if
# the process holding the OS lock exits or releases the device.
return None
except OSError as exc:
# Awake is optional. Never bypass ownership or let an ACL / I/O
# failure abort device connection, resume, or the host monitor.
if device_id not in self._ownership_warnings:
logger.warning(
f"Could not acquire awake ownership on {device_id}; skipping optional "
f"stay-awake policy: {exc}"
)
self._ownership_warnings.add(device_id)
return None
self._ownership_warnings.discard(device_id)
try:
strategy = _configure_usb_stay_awake(device_id)
except BaseException:
+7 -1
View File
@@ -584,6 +584,7 @@ class Agent:
or getattr(getattr(task, "request", None), "task_name", None)
)
device_lock = None
execution_started = False
try:
# Construct the runtime only inside the task's cleanup boundary. A
# cancelled queue wait or setup failure must not leave version pins.
@@ -761,6 +762,7 @@ class Agent:
publish_startup_progress(
"environment_ready", "Device environment is ready", session_id=str(sess_id)
)
execution_started = True
try:
if request.profile and request.profile.lower() == "flash":
logger.info(f"[{task_name}] Invoking FlashRunner reactive loop...")
@@ -972,7 +974,11 @@ class Agent:
from artemis.runtime.task_result import save_failure_result
if sess_id:
save_failure_result(str(sess_id), err)
save_failure_result(
str(sess_id),
err,
code="execution_failed" if execution_started else "initialization_failed",
)
if context.data_engine:
context.data_engine.end_session("failed")
+26 -10
View File
@@ -317,12 +317,32 @@ async def _wait_for_resume(pause_file: Path) -> bool:
"""Wait for the pause file to be cleared. Returns False on deadline."""
deadline = float(getattr(settings, "LLM_PAUSE_TIMEOUT_SECONDS", 0.0) or 0.0)
waited = 0.0
while pause_file.exists():
if deadline > 0 and waited >= deadline:
return False
await asyncio.sleep(1)
waited += 1
return True
try:
owned = pause_file.stat()
except FileNotFoundError:
return True
try:
while pause_file.exists():
if deadline > 0 and waited >= deadline:
return False
await asyncio.sleep(1)
waited += 1
return True
finally:
# Cancellation (stop, guidance, outer deadline) also ends this wait.
# Do not leave a global pause behind or remove a newer request's pause.
try:
current = pause_file.stat()
if (current.st_ino, current.st_mtime_ns, current.st_size) == (
owned.st_ino,
owned.st_mtime_ns,
owned.st_size,
):
pause_file.unlink(missing_ok=True)
except FileNotFoundError:
pass
except OSError as exc:
llm_logger.warning("Could not clear finished LLM pause: %s", exc)
async def _wait_for_breaker(key: str) -> None:
@@ -510,10 +530,6 @@ async def _run_with_recovery[T](
"LLM pause deadline"
f" ({settings.LLM_PAUSE_TIMEOUT_SECONDS:.0f}s) exceeded; giving up."
)
try:
pause_file.unlink(missing_ok=True)
except OSError:
pass
raise LLMExhaustedError(
str(last_error), failure=last_failure, cause=last_error
) from last_error