fix(runtime): recover awake ownership and batch model failures
This commit is contained in:
1 parent
6b2c0f199d
commit
baf069854b
17 files changed
+477
-32
No files matched your search
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user