Three final-review deviations closed: I1 (session-end release): mcp SDK 1.28.1 exposes no per-session shutdown callback (only a server-level lifespan). Lower the McpBusyTracker default TTL from 60s to 20s and update spec §6.5, Q5/R3, D9, and docs/MCP_INTEGRATION.md concurrency section to document the TTL-only recovery path. 20s is short enough to recover within one 30s heartbeat interval but long enough that an active session does not lose its lease during normal operator pauses. I2 (JSON-RPC error shape): FastMCP Tool.run wraps every non- UrlElicitationRequiredError exception (including McpError with typed ErrorData) into ToolError, which the lowlevel call_tool handler serializes as CallToolResult(isError=true, content=[TextContent(...)]). There is no public path that surfaces JSON-RPC -32000 with structured data.busy_owner from a tool call site. Update spec §7 error matrix and docs/MCP_INTEGRATION.md error table to document the actual wire shape; busy_owner now lives in the text content. I3 (typing): mcp_server: Any = None -> FastMCP | None = None via TYPE_CHECKING, keeping the mcp import lazy (matches precedent elsewhere in the codebase) while adding static type checking at the create_console_app boundary. Tests added (4): - test_default_ttl_is_20_seconds — locks I1's new default TTL - test_default_ttl_recovers_dead_session_within_one_window — locks I1's recovery semantics (lease sweeped on next read after 20s) - test_busy_error_wire_shape_is_calltoolresult_iserror — pins I2's wire envelope via Tool.run + lowlevel Server._make_error_result - test_busy_error_text_includes_cloud_assignment_owner — same for the cloud_assignment busy_owner branch Full non-integration suite: 697 passed / 54 deselected (was 693 / 54). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
176 lines
6.0 KiB
Python
176 lines
6.0 KiB
Python
from __future__ import annotations
|
|
|
|
import threading
|
|
from datetime import UTC, datetime
|
|
|
|
from host_agent.mcp_lock import McpBusyTracker
|
|
|
|
|
|
def _tracker_with_now() -> tuple[McpBusyTracker, list[datetime]]:
|
|
times: list[datetime] = []
|
|
|
|
def now() -> datetime:
|
|
return times[-1] if times else datetime(2026, 1, 1, tzinfo=UTC)
|
|
|
|
tracker = McpBusyTracker(ttl_seconds=60.0, now=now)
|
|
return tracker, times
|
|
|
|
|
|
def test_acquire_succeeds_on_empty() -> None:
|
|
tracker, _ = _tracker_with_now()
|
|
assert tracker.acquire("phone-1", "sess-a") is True
|
|
assert "phone-1" in tracker.busy_device_ids()
|
|
|
|
|
|
def test_acquire_fails_when_held_by_other_session() -> None:
|
|
tracker, _ = _tracker_with_now()
|
|
assert tracker.acquire("phone-1", "sess-a") is True
|
|
assert tracker.acquire("phone-1", "sess-b") is False
|
|
|
|
|
|
def test_acquire_is_idempotent_for_same_session() -> None:
|
|
tracker, _ = _tracker_with_now()
|
|
assert tracker.acquire("phone-1", "sess-a") is True
|
|
# Same session re-acquiring is allowed (acts as renew).
|
|
assert tracker.acquire("phone-1", "sess-a") is True
|
|
|
|
|
|
def test_renew_refreshes_last_seen() -> None:
|
|
tracker, times = _tracker_with_now()
|
|
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
|
|
tracker.acquire("phone-1", "sess-a")
|
|
initial = tracker.snapshot()[0]
|
|
times.append(datetime(2026, 1, 1, 12, 0, 30, tzinfo=UTC))
|
|
assert tracker.renew("phone-1", "sess-a") is True
|
|
refreshed = tracker.snapshot()[0]
|
|
assert refreshed.last_seen_at > initial.last_seen_at
|
|
|
|
|
|
def test_renew_fails_when_held_by_other() -> None:
|
|
tracker, _ = _tracker_with_now()
|
|
tracker.acquire("phone-1", "sess-a")
|
|
assert tracker.renew("phone-1", "sess-b") is False
|
|
|
|
|
|
def test_release_returns_freed_device_ids() -> None:
|
|
tracker, _ = _tracker_with_now()
|
|
tracker.acquire("phone-1", "sess-a")
|
|
tracker.acquire("phone-2", "sess-a")
|
|
freed = tracker.release("sess-a")
|
|
assert sorted(freed) == ["phone-1", "phone-2"]
|
|
assert tracker.busy_device_ids() == []
|
|
|
|
|
|
def test_release_only_frees_caller_session() -> None:
|
|
tracker, _ = _tracker_with_now()
|
|
tracker.acquire("phone-1", "sess-a")
|
|
tracker.acquire("phone-1", "sess-b") # fails
|
|
freed = tracker.release("sess-b")
|
|
assert freed == []
|
|
assert "phone-1" in tracker.busy_device_ids()
|
|
|
|
|
|
def test_ttl_sweeps_expired_leases() -> None:
|
|
tracker, times = _tracker_with_now()
|
|
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
|
|
tracker.acquire("phone-1", "sess-a")
|
|
# Advance past TTL without renew.
|
|
times.append(datetime(2026, 1, 1, 12, 1, 1, tzinfo=UTC)) # 61s later
|
|
assert tracker.busy_device_ids() == []
|
|
|
|
|
|
def test_renew_after_ttl_tolerates_same_session() -> None:
|
|
"""Scene 10: lease expired but session_id matches -> re-acquire."""
|
|
tracker, times = _tracker_with_now()
|
|
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
|
|
tracker.acquire("phone-1", "sess-a")
|
|
times.append(datetime(2026, 1, 1, 12, 1, 1, tzinfo=UTC)) # expired
|
|
# renew from the same session should succeed (re-acquire).
|
|
assert tracker.renew("phone-1", "sess-a") is True
|
|
assert "phone-1" in tracker.busy_device_ids()
|
|
|
|
|
|
def test_snapshot_matches_busy_device_ids() -> None:
|
|
tracker, _ = _tracker_with_now()
|
|
tracker.acquire("phone-1", "sess-a")
|
|
tracker.acquire("phone-2", "sess-a")
|
|
snap = tracker.snapshot()
|
|
assert {lease.device_id for lease in snap} == set(tracker.busy_device_ids())
|
|
|
|
|
|
def test_wait_until_usable_succeeds_when_free() -> None:
|
|
tracker, _ = _tracker_with_now()
|
|
ok = tracker.wait_until_usable("phone-1", "sess-a", timeout=1.0, poll_interval=0.01)
|
|
assert ok is True
|
|
assert "phone-1" in tracker.busy_device_ids()
|
|
|
|
|
|
def test_wait_until_usable_returns_false_on_timeout() -> None:
|
|
tracker, _ = _tracker_with_now()
|
|
tracker.acquire("phone-1", "sess-a")
|
|
ok = tracker.wait_until_usable("phone-1", "sess-b", timeout=0.1, poll_interval=0.02)
|
|
assert ok is False
|
|
|
|
|
|
def test_wait_until_usable_blocks_then_succeeds_when_released() -> None:
|
|
tracker, _ = _tracker_with_now()
|
|
tracker.acquire("phone-1", "sess-a")
|
|
|
|
def releaser() -> None:
|
|
import time
|
|
|
|
time.sleep(0.05)
|
|
tracker.release("sess-a")
|
|
|
|
t = threading.Thread(target=releaser)
|
|
t.start()
|
|
try:
|
|
ok = tracker.wait_until_usable(
|
|
"phone-1", "sess-b", timeout=2.0, poll_interval=0.02
|
|
)
|
|
assert ok is True
|
|
finally:
|
|
t.join()
|
|
|
|
|
|
def test_wait_until_usable_blocks_then_fails_when_cloud_remains_busy() -> None:
|
|
tracker, _ = _tracker_with_now()
|
|
ok = tracker.wait_until_usable(
|
|
"phone-1",
|
|
"sess-a",
|
|
timeout=0.1,
|
|
poll_interval=0.02,
|
|
cloud_busy_check=lambda: True,
|
|
)
|
|
assert ok is False
|
|
assert tracker.busy_device_ids() == []
|
|
|
|
|
|
def test_default_ttl_is_20_seconds() -> None:
|
|
"""The McpBusyTracker default TTL is 20s: short enough to recover from a
|
|
dead MCP session within a heartbeat interval without an explicit release
|
|
callback (mcp SDK 1.28.1 has no per-session shutdown hook), but long
|
|
enough that an actively-busy session does not lose its lease during
|
|
normal operator pauses."""
|
|
tracker = McpBusyTracker()
|
|
assert tracker._ttl == 20.0
|
|
|
|
|
|
def test_default_ttl_recovers_dead_session_within_one_window() -> None:
|
|
"""With the 20s default, a session that never renews its lease is
|
|
reaped within one TTL window on the next read. This is the
|
|
concrete fallback behavior for I1 (no FastMCP session-end hook)."""
|
|
times: list[datetime] = []
|
|
|
|
def now() -> datetime:
|
|
return times[-1] if times else datetime(2026, 1, 1, tzinfo=UTC)
|
|
|
|
tracker = McpBusyTracker(now=now) # default 20s TTL
|
|
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
|
|
assert tracker.acquire("phone-1", "dead-session") is True
|
|
# No renew: advance 21s. Lease should be swept on next read.
|
|
times.append(datetime(2026, 1, 1, 12, 0, 21, tzinfo=UTC))
|
|
assert tracker.busy_device_ids() == []
|
|
# New session can now acquire cleanly (no stale-busy contamination).
|
|
assert tracker.acquire("phone-1", "new-session") is True
|