Skip to content

Commit d9bf2c5

Browse files
authored
fix(game_eval): carve the not-ready path out into EVAL_GAME_NOT_READY (#518) (#519)
The opaque INTERNAL_ERROR 'regression' on game_eval (1.42%->6.15%) was a thin-baseline cohort artifact plus #500 reclassifying ~15s TimeoutErrors into fast ~3s INTERNAL_ERRORs; the genuine ~10s hang is flat ~2.8%. Carve the play-session-up-but-capture-not-ready failure into its own EVAL_GAME_NOT_READY code (mirrors #491, and is #518's Suggested Action #2) so the opaque bucket again means 'the eval truly hung'. Relabel only; no timing machinery touched. Refs #518.
1 parent be40e1c commit d9bf2c5

7 files changed

Lines changed: 67 additions & 12 deletions

File tree

plugin/addons/godot_ai/debugger/mcp_debugger_plugin.gd

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -433,8 +433,12 @@ func _wait_then_eval(
433433
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
434434
await tree.process_frame
435435
if not is_game_capture_ready():
436-
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
437-
"Game-side autoload never registered its debugger capture within %ds. Is the game actually running? Start it with project_run / the editor's Play button, then retry. If it IS running, check Project Settings → Autoload for _mcp_game_helper (added automatically when the plugin is enabled)." % int(EVAL_READY_WAIT_SEC))
436+
## #518: EVAL_GAME_NOT_READY (not INTERNAL_ERROR) — the play session is up
437+
## but the game-side capture didn't register within the short wait. Fast
438+
## and caller-actionable; classifying it apart from the opaque 10s hang
439+
## keeps the INTERNAL_ERROR telemetry bucket meaning "the eval truly hung".
440+
_send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
441+
"Game-side capture didn't register within %ds. The play session is already running, so the game is most likely still booting — wait a moment and retry. If it persists, the _mcp_game_helper autoload is missing or disabled (Project Settings → Autoload; added automatically when the plugin is enabled), or the game uses a custom main loop." % int(EVAL_READY_WAIT_SEC))
438442
return
439443
_send_eval(tree, code, request_id, connection, timeout_sec)
440444

@@ -448,8 +452,11 @@ func _send_eval(
448452
) -> void:
449453
var session: EditorDebuggerSession = _first_active_session()
450454
if session == null:
451-
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
452-
"No active debugger session — is the game actually running?")
455+
## #518: capture reported ready but the debugger session is no longer live
456+
## (the game just stopped / is restarting) — a not-ready race, so the same
457+
## caller-actionable EVAL_GAME_NOT_READY rather than the opaque hang bucket.
458+
_send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
459+
"Game-side capture registered but its debugger session is no longer active — the game likely just stopped or is restarting. Confirm it's running and retry.")
453460
return
454461

455462
var timer: SceneTreeTimer = tree.create_timer(timeout_sec)

plugin/addons/godot_ai/utils/error_codes.gd

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ const DEFERRED_TIMEOUT := "DEFERRED_TIMEOUT"
2929
# game_eval failure codes (#490) — keep in sync with protocol/errors.py
3030
const EVAL_COMPILE_ERROR := "EVAL_COMPILE_ERROR"
3131
const EVAL_RUNTIME_ERROR := "EVAL_RUNTIME_ERROR"
32+
## #518: the play session is up (EditorInterface.is_playing_scene() is true, so
33+
## editor_handler's EDITOR_NOT_READY "game is not running" gate already passed)
34+
## but the game-side _mcp_game_helper autoload never registered its debugger
35+
## capture within EVAL_READY_WAIT_SEC. Carved out of INTERNAL_ERROR so this
36+
## boot-window / missing-autoload race stops masquerading as the opaque "eval
37+
## hung" 10s timeout in telemetry — the same split #490 made for compile/runtime
38+
## errors. NOT a hang: it fires fast (~3s) and is caller-actionable (let the game
39+
## finish booting and retry, or check the autoload is enabled).
40+
const EVAL_GAME_NOT_READY := "EVAL_GAME_NOT_READY"
3241
## audit-v2 #21 (issue #365): finer-grained codes carved out of the 471
3342
## INVALID_PARAMS sites so agents can distinguish recoverable input
3443
## errors from structural ones. INVALID_PARAMS stays for genuinely

src/godot_ai/handlers/editor.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -345,10 +345,13 @@ async def game_eval(runtime: DirectRuntime, code: str) -> dict:
345345
346346
Errors come back fast and actionable (#490): a syntax/parse error returns
347347
``EVAL_COMPILE_ERROR`` and a runtime error returns ``EVAL_RUNTIME_ERROR``
348-
with the real message and resolved line, instead of a generic timeout. A
349-
genuine infinite loop / never-firing await still hits the timeout. Note
350-
that ``await`` (timers, signals, frames) only progresses while the game
351-
window is focused; a backgrounded play-in-editor game has a frozen idle
352-
loop, so an awaiting eval reads as a timeout until the game is focused.
348+
with the real message and resolved line, instead of a generic timeout.
349+
``EVAL_GAME_NOT_READY`` (#518) means the play session is up but the
350+
game-side capture hasn't registered yet — let the game finish launching and
351+
retry, or check the ``_mcp_game_helper`` autoload is enabled. A genuine
352+
infinite loop / never-firing await still hits the timeout. Note that
353+
``await`` (timers, signals, frames) only progresses while the game window is
354+
focused; a backgrounded play-in-editor game has a frozen idle loop, so an
355+
awaiting eval reads as a timeout until the game is focused.
353356
"""
354357
return await runtime.send_command("game_eval", {"code": code}, timeout=15.0)

src/godot_ai/protocol/errors.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ class ErrorCode(StrEnum):
1818
# actionable reply. Keep in sync with utils/error_codes.gd.
1919
EVAL_COMPILE_ERROR = "EVAL_COMPILE_ERROR"
2020
EVAL_RUNTIME_ERROR = "EVAL_RUNTIME_ERROR"
21+
# #518: the play session is up but the game-side autoload never registered
22+
# its debugger capture within the readiness wait (boot-window race, worst on
23+
# Windows, or a missing/disabled autoload). Carved out of INTERNAL_ERROR so
24+
# this fast (~3s), caller-actionable failure stops being counted as the
25+
# opaque "eval hung" 10s timeout. Keep in sync with utils/error_codes.gd.
26+
EVAL_GAME_NOT_READY = "EVAL_GAME_NOT_READY"
2127
## audit-v2 #21 (issue #365): finer-grained codes carved out of the
2228
## 471 INVALID_PARAMS sites so agents can distinguish recoverable
2329
## input errors from structural ones. INVALID_PARAMS stays for

src/godot_ai/tools/editor.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@
3939
'await' so user code can await internally. Errors return fast and
4040
actionable: EVAL_COMPILE_ERROR for a syntax/parse error,
4141
EVAL_RUNTIME_ERROR (with the real message + line) for a runtime
42-
error; a genuine infinite loop / never-firing await still times out.
43-
'await' only progresses while the game window is focused."""
42+
error; EVAL_GAME_NOT_READY if the game isn't ready yet — still
43+
launching (retry once it's up) or the _mcp_game_helper autoload is
44+
missing/disabled; a genuine infinite loop / never-firing await still
45+
times out. 'await' only progresses while the game window is focused."""
4446

4547

4648
def register_editor_tools(mcp: FastMCP, *, include_non_core: bool = True) -> None:

test_project/tests/test_game_eval_errors.gd

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,3 +394,28 @@ func test_eval_grace_noop_when_already_compiled() -> void:
394394
assert_true(plugin._pending.has(rid), "a compiled eval is untouched by the grace timer")
395395
assert_eq(conn.captured.size(), 0, "no compile error for a compiled eval")
396396
conn.free()
397+
398+
399+
func test_send_eval_without_active_session_replies_game_not_ready() -> void:
400+
## #518: with no live debugger session, _send_eval replies with the
401+
## caller-actionable EVAL_GAME_NOT_READY rather than the opaque INTERNAL_ERROR
402+
## that the telemetry bucket now reserves for a genuine ~10s eval hang. (The
403+
## no-session branch returns before touching `tree`, so a bare plugin is safe.)
404+
var tree := Engine.get_main_loop() as SceneTree
405+
if tree == null:
406+
skip("No SceneTree available")
407+
return
408+
var plugin := McpDebuggerPlugin.new()
409+
## Gate BEFORE calling _send_eval: a bare plugin normally has no session, but
410+
## if one were present _send_eval would take its live path (arm timers, send a
411+
## real mcp:eval into the running game). Skip first so the test never has side
412+
## effects in that case rather than bailing after the fact.
413+
if plugin._first_active_session() != null:
414+
skip("an active debugger session is present; no-session branch not exercised")
415+
return
416+
var conn := _StubConnection.new()
417+
plugin._send_eval(tree, "return 1", "rid-no-session", conn, 10.0)
418+
assert_eq(conn.captured.size(), 1, "exactly one deferred reply is sent")
419+
assert_eq(conn.captured[0]["payload"]["error"]["code"], ErrorCodes.EVAL_GAME_NOT_READY,
420+
"no active debugger session replies with EVAL_GAME_NOT_READY, not INTERNAL_ERROR")
421+
conn.free()

tests/unit/test_game_eval.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,12 @@ async def test_game_eval_passes_code_verbatim():
6767

6868

6969
def test_eval_error_codes_exist():
70-
"""The two codes the plugin emits for fast game_eval failures."""
70+
"""The codes the plugin emits for fast game_eval failures."""
7171
assert ErrorCode.EVAL_COMPILE_ERROR == "EVAL_COMPILE_ERROR"
7272
assert ErrorCode.EVAL_RUNTIME_ERROR == "EVAL_RUNTIME_ERROR"
73+
# #518: the play-session-up-but-capture-not-ready race, carved out of
74+
# INTERNAL_ERROR so it stops being counted as a genuine eval hang.
75+
assert ErrorCode.EVAL_GAME_NOT_READY == "EVAL_GAME_NOT_READY"
7376

7477

7578
class _RaisingGameEvalClient:

0 commit comments

Comments
 (0)