-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathrefresh_unity.py
More file actions
300 lines (261 loc) · 11.7 KB
/
Copy pathrefresh_unity.py
File metadata and controls
300 lines (261 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
from __future__ import annotations
import asyncio
import logging
import os
import time
from collections.abc import Awaitable, Callable
from typing import Annotated, Any, Literal
from fastmcp import Context
from mcp.types import ToolAnnotations
from models import MCPResponse
from services.registry import mcp_for_unity_tool
from services.tools import get_unity_instance_from_context
from services.tools.editor_operation_lease import (
operation_busy_response,
operation_owner_from_context,
try_acquire_editor_operation_lease,
)
import transport.unity_transport as unity_transport
import transport.legacy.unity_connection as _legacy_conn
from transport.legacy.unity_connection import _extract_response_reason
from services.state.external_changes_scanner import external_changes_scanner
import services.resources.editor_state as editor_state
logger = logging.getLogger(__name__)
# Blocking reasons that indicate Unity is actually busy (not just stale status).
# Must match activityPhase values from EditorStateCache.cs
_REAL_BLOCKING_REASONS = {"compiling", "domain_reload", "running_tests", "asset_import"}
def _in_pytest() -> bool:
"""Return True when running inside pytest to avoid polling unmocked resources."""
return "PYTEST_CURRENT_TEST" in os.environ
async def wait_for_editor_ready(ctx: Context, timeout_s: float = 30.0) -> tuple[bool, float]:
"""Poll editor_state until Unity is ready for tool calls.
Returns (ready, elapsed_seconds). Treats exceptions from
get_editor_state as "not ready yet" so the loop survives transient
connection errors during domain reload.
"""
if _in_pytest():
return (True, 0.0)
start = time.monotonic()
while time.monotonic() - start < timeout_s:
try:
state_resp = await editor_state.get_editor_state(ctx)
state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp
data = (state or {}).get("data") if isinstance(state, dict) else None
advice = (data or {}).get("advice") if isinstance(data, dict) else None
if isinstance(advice, dict):
if advice.get("ready_for_tools") is True:
return (True, time.monotonic() - start)
blocking = set(advice.get("blocking_reasons") or [])
if not (blocking & _REAL_BLOCKING_REASONS):
return (True, time.monotonic() - start)
except Exception:
pass # not ready yet — keep polling
await asyncio.sleep(0.25)
return (False, time.monotonic() - start)
def is_reloading_rejection(resp: Any) -> bool:
"""True when Unity rejected a command because it thinks it is reloading.
The command was never executed, so retrying is safe.
"""
if not isinstance(resp, dict) or resp.get("success"):
return False
data = resp.get("data") or {}
return data.get("reason") == "reloading" and resp.get("hint") == "retry"
def is_connection_lost_after_send(resp: Any) -> bool:
"""True when a mutation's response indicates TCP was lost after command was sent.
Script mutations trigger domain reload which kills the TCP connection.
The mutation was likely executed but the response was lost.
"""
if isinstance(resp, dict):
if resp.get("success"):
return False
err = (resp.get("error") or resp.get("message") or "").lower()
else:
if getattr(resp, "success", None):
return False
err = (getattr(resp, "error", "") or "").lower()
return "connection closed" in err or "disconnected" in err or "aborted" in err
async def send_mutation(
ctx: Context,
unity_instance: str | None,
command: str,
params: dict[str, Any],
*,
verify_after_disconnect: Callable[[], Awaitable[dict | None]] | None = None,
) -> dict | Any:
"""Send a non-idempotent mutation with reload recovery.
Handles the full retry/recovery pattern for script mutations:
1. Send with retry_on_reload=False (don't re-send if Unity is reloading)
2. If reloading rejection (command never executed) → wait + retry once
3. If connection lost after send → wait + verify via callback
4. Wait for editor readiness before returning
Args:
verify_after_disconnect: async callable returning a replacement response
dict if the mutation was verified after connection loss, or None to
keep the original error response.
"""
resp = await unity_transport.send_with_unity_instance(
_legacy_conn.async_send_command_with_retry,
unity_instance,
command,
params,
retry_on_reload=False,
)
if is_reloading_rejection(resp):
await wait_for_editor_ready(ctx)
resp = await unity_transport.send_with_unity_instance(
_legacy_conn.async_send_command_with_retry,
unity_instance,
command,
params,
retry_on_reload=False,
)
if is_connection_lost_after_send(resp) and verify_after_disconnect:
await wait_for_editor_ready(ctx)
verified = await verify_after_disconnect()
if verified is not None:
resp = verified
await wait_for_editor_ready(ctx)
return resp
async def verify_edit_by_sha(
unity_instance: str | None,
name: str,
path: str,
pre_sha: str | None,
) -> bool:
"""Verify a script edit was applied by comparing SHA before and after.
Returns True if the file's SHA changed (edit likely applied).
"""
if not pre_sha:
return False
try:
verify = await unity_transport.send_with_unity_instance(
_legacy_conn.async_send_command_with_retry,
unity_instance,
"manage_script",
{"action": "get_sha", "name": name, "path": path},
)
if isinstance(verify, dict) and verify.get("success"):
new_sha = (verify.get("data") or {}).get("sha256")
return bool(new_sha and new_sha != pre_sha)
except Exception as exc:
logger.debug(
"Failed to verify edit after disconnect for %s at %s: %r",
name, path, exc,
)
return False
@mcp_for_unity_tool(
description="Request a Unity asset database refresh and optionally a script compilation. Can optionally wait for readiness.",
annotations=ToolAnnotations(
title="Refresh Unity",
destructiveHint=True,
),
)
async def refresh_unity(
ctx: Context,
mode: Annotated[Literal["if_dirty", "force"], "Refresh mode"] = "if_dirty",
scope: Annotated[Literal["assets", "scripts", "all"],
"Refresh scope"] = "all",
compile: Annotated[Literal["none", "request"],
"Whether to request compilation"] = "none",
wait_for_ready: Annotated[bool,
"If true, wait until editor_state.advice.ready_for_tools is true"] = True,
) -> MCPResponse | dict[str, Any]:
unity_instance = await get_unity_instance_from_context(ctx)
lease, busy_lease = try_acquire_editor_operation_lease(
unity_instance,
"refresh_unity",
owner=operation_owner_from_context(ctx),
)
if busy_lease is not None:
return operation_busy_response(busy_lease)
try:
return await _refresh_unity_locked(ctx, unity_instance, mode, scope, compile, wait_for_ready)
finally:
if lease is not None:
lease.release()
async def _refresh_unity_locked(
ctx: Context,
unity_instance: str | None,
mode: str,
scope: str,
compile: str,
wait_for_ready: bool,
) -> MCPResponse | dict[str, Any]:
params: dict[str, Any] = {
"mode": mode,
"scope": scope,
"compile": compile,
"wait_for_ready": bool(wait_for_ready),
}
recovered_from_disconnect = False
# Don't retry on reload - refresh_unity triggers compilation/reload,
# so retrying would cause multiple reloads (issue #577)
response = await unity_transport.send_with_unity_instance(
_legacy_conn.async_send_command_with_retry,
unity_instance,
"refresh_unity",
params,
retry_on_reload=False,
)
# Handle connection errors during refresh/compile gracefully.
# Unity disconnects during domain reload, which is expected behavior - not a failure.
# If we sent the command and connection closed, the refresh was likely triggered successfully.
# Convert MCPResponse to dict if needed
response_dict = response if isinstance(response, dict) else (response.model_dump() if hasattr(response, "model_dump") else response.__dict__)
if not response_dict.get("success", True):
hint = response_dict.get("hint")
err = (response_dict.get("error") or response_dict.get("message") or "").lower()
reason = _extract_response_reason(response_dict)
# Connection closed/timeout during compile = refresh was triggered, Unity is reloading
# This is SUCCESS, not failure - don't return error to prevent Claude Code from retrying
is_connection_lost = (
"connection closed" in err
or "disconnected" in err
or "aborted" in err # WinError 10053: connection aborted
or "timeout" in err
or reason == "reloading"
)
if is_connection_lost and compile == "request":
# EXPECTED BEHAVIOR: When compile="request", Unity triggers domain reload which
# causes connection to close mid-command. This is NOT a failure - the refresh
# was successfully triggered. Treating this as success prevents Claude Code from
# retrying unnecessarily (which would cause multiple domain reloads - issue #577).
# The subsequent wait_for_ready loop (below) will verify Unity becomes ready.
logger.info("refresh_unity: Connection lost during compile (expected - domain reload triggered)")
recovered_from_disconnect = True
elif hint == "retry" or "could not connect" in err:
# Retryable error - proceed to wait loop if wait_for_ready
if not wait_for_ready:
return MCPResponse(**response_dict)
recovered_from_disconnect = True
else:
# Non-recoverable error - connection issue unrelated to domain reload
logger.warning(f"refresh_unity: Non-recoverable error (compile={compile}): {err[:100]}")
return MCPResponse(**response_dict)
# Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly,
# poll the canonical editor_state resource until ready or timeout.
ready_confirmed = False
if wait_for_ready:
ready_confirmed, _ = await wait_for_editor_ready(ctx, timeout_s=60.0)
# If we timed out without confirming readiness, log and return failure
if not ready_confirmed:
logger.warning("refresh_unity: Timed out after 60s waiting for editor to become ready")
return MCPResponse(
success=False,
message="Refresh triggered but timed out after 60s waiting for editor readiness.",
data={"timeout": True, "wait_seconds": 60.0},
)
# After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly.
try:
inst = unity_instance or await editor_state.infer_single_instance_id(ctx)
if inst:
external_changes_scanner.clear_dirty(inst)
except Exception:
pass
if recovered_from_disconnect:
return MCPResponse(
success=True,
message="Refresh recovered after Unity disconnect/retry; editor is ready.",
data={"recovered_from_disconnect": True},
)
return MCPResponse(**response_dict) if isinstance(response, dict) else response