feat(host-agent): fail-fast cloud assignment when MCP holds device
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from cloud.internal_api.models import AssignmentModel
|
from cloud.internal_api.models import AssignmentModel
|
||||||
from core.models import Task
|
from core.models import Task
|
||||||
@@ -11,6 +11,9 @@ from host_agent.planner_context import bind_planner_execution_context
|
|||||||
from host_agent.progress import TaskProgressHolder, TaskProgressSnapshot
|
from host_agent.progress import TaskProgressHolder, TaskProgressSnapshot
|
||||||
from runtime.task import is_cancellation_reason
|
from runtime.task import is_cancellation_reason
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from host_agent.mcp_lock import McpBusyTracker
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AssignmentExecutionResult:
|
class AssignmentExecutionResult:
|
||||||
@@ -20,9 +23,15 @@ class AssignmentExecutionResult:
|
|||||||
|
|
||||||
|
|
||||||
class AssignmentExecutor:
|
class AssignmentExecutor:
|
||||||
def __init__(self, factories: ExecutionFactories) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
factories: ExecutionFactories,
|
||||||
|
*,
|
||||||
|
mcp_busy_tracker: McpBusyTracker | None = None,
|
||||||
|
) -> None:
|
||||||
self.factories = factories
|
self.factories = factories
|
||||||
self._progress = TaskProgressHolder()
|
self._progress = TaskProgressHolder()
|
||||||
|
self._mcp_busy_tracker = mcp_busy_tracker
|
||||||
|
|
||||||
def latest_progress(self) -> TaskProgressSnapshot | None:
|
def latest_progress(self) -> TaskProgressSnapshot | None:
|
||||||
"""Latest step progress reported by the currently-running assignment."""
|
"""Latest step progress reported by the currently-running assignment."""
|
||||||
@@ -36,6 +45,16 @@ class AssignmentExecutor:
|
|||||||
stop_reason: Callable[[], str | None] | None = None,
|
stop_reason: Callable[[], str | None] | None = None,
|
||||||
) -> AssignmentExecutionResult:
|
) -> AssignmentExecutionResult:
|
||||||
self._progress.clear()
|
self._progress.clear()
|
||||||
|
if self._mcp_busy_tracker is not None and (
|
||||||
|
assignment.device_id in self._mcp_busy_tracker.busy_device_ids()
|
||||||
|
):
|
||||||
|
return AssignmentExecutionResult(
|
||||||
|
status="failed",
|
||||||
|
failure_reason=(
|
||||||
|
f"device {assignment.device_id} is held by an active "
|
||||||
|
"MCP session"
|
||||||
|
),
|
||||||
|
)
|
||||||
with bind_planner_execution_context(assignment):
|
with bind_planner_execution_context(assignment):
|
||||||
if should_stop is not None and should_stop():
|
if should_stop is not None and should_stop():
|
||||||
reason = stop_reason() if stop_reason is not None else None
|
reason = stop_reason() if stop_reason is not None else None
|
||||||
|
|||||||
@@ -180,6 +180,63 @@ def test_workflow_assignment_maps_cancellation_stop_to_cancelled_status() -> Non
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_fails_fast_when_mcp_session_holds_device() -> None:
|
||||||
|
"""Cloud assignment arriving for a device currently held by an MCP
|
||||||
|
session must fail immediately rather than fight for the device."""
|
||||||
|
from host_agent.mcp_lock import McpBusyTracker
|
||||||
|
|
||||||
|
tracker = McpBusyTracker()
|
||||||
|
tracker.acquire("phone-1", "sess-mcp")
|
||||||
|
executor = AssignmentExecutor(
|
||||||
|
_build_factories(),
|
||||||
|
mcp_busy_tracker=tracker,
|
||||||
|
)
|
||||||
|
assignment = _assignment(device_id="phone-1")
|
||||||
|
result = executor.execute(assignment)
|
||||||
|
assert result.status == "failed"
|
||||||
|
assert "MCP" in (result.failure_reason or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_skips_check_when_tracker_is_none() -> None:
|
||||||
|
"""Default backward-compat: no tracker → no fail-fast."""
|
||||||
|
executor = AssignmentExecutor(_build_factories())
|
||||||
|
# Without a real workflow store / task runner this test verifies the
|
||||||
|
# entry-point path doesn't raise on the mcp_busy check.
|
||||||
|
# We use a goal + a mock runner factory so execute() runs through.
|
||||||
|
assignment = _assignment()
|
||||||
|
result = executor.execute(assignment)
|
||||||
|
# Should run through normally (not fail on MCP check)
|
||||||
|
assert result.status == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_factories() -> ExecutionFactories:
|
||||||
|
"""Shared factory fixture used by MCP-hold tests."""
|
||||||
|
received: list[Task] = []
|
||||||
|
|
||||||
|
class FakeTaskRunner:
|
||||||
|
def run(self, task: Task) -> Task:
|
||||||
|
received.append(task)
|
||||||
|
task.status = "completed"
|
||||||
|
return task
|
||||||
|
|
||||||
|
class FakeMetadataStore:
|
||||||
|
def create_task(
|
||||||
|
self,
|
||||||
|
task: Task,
|
||||||
|
*,
|
||||||
|
source_task_id: str | None = None,
|
||||||
|
source_attempt: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return ExecutionFactories(
|
||||||
|
task_runner_factory=lambda: FakeTaskRunner(), # type: ignore[arg-type,return-value]
|
||||||
|
workflow_runner_factory=lambda: object(), # type: ignore[arg-type,return-value]
|
||||||
|
workflow_store=object(), # type: ignore[arg-type]
|
||||||
|
metadata_store=FakeMetadataStore(), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_workflow_fails_without_running() -> None:
|
def test_unknown_workflow_fails_without_running() -> None:
|
||||||
class FakeWorkflowStore:
|
class FakeWorkflowStore:
|
||||||
def get_definition(self, definition_id: str):
|
def get_definition(self, definition_id: str):
|
||||||
|
|||||||
Reference in New Issue
Block a user