Skip to content

Commit b896065

Browse files
dsarnoclaude
andcommitted
game_eval: require an ack before treating a missing compile beacon as a parse error (#490 review)
The 3s compile-grace timer inferred a parse error purely from the ABSENCE of mcp:eval_compiled, conflating two cases: (a) the source failed to parse (game's _handle_eval aborts at reload() before the beacon), and (b) the game main thread hasn't serviced the eval message yet — a long frame/load, or a CPU-bound prior eval (now possible with concurrent evals). In case (b) a valid eval was false-failed as EVAL_COMPILE_ERROR, and worse, _clear_pending dropped the eventual real eval_compiled/eval_response as an unknown request_id, losing the actual result. Fix (the eval_ack the #490 design originally called for): the game sends mcp:eval_ack at the top of _handle_eval, BEFORE reload(), so it survives a parse-error abort. The editor now fires EVAL_COMPILE_ERROR only when the eval was acked (received + reload started) but never compiled — a positive compile-failure signal. A missing ack means "not serviced yet", so the grace defers to the normal timeout and leaves _pending intact, so the real reply is still delivered. Refactored the grace lambda into _on_eval_grace(request_id) so the gate is unit-testable. Added editor-side tests: _on_eval_ack sets the flag; grace fires EVAL_COMPILE_ERROR when acked-but-not-compiled, defers (no false error, pending intact) when not acked, and is a no-op when already compiled. Full GDScript suite green (1366); all 6 live cases re-verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ff2623e commit b896065

3 files changed

Lines changed: 106 additions & 14 deletions

File tree

plugin/addons/godot_ai/debugger/mcp_debugger_plugin.gd

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ func _capture(message: String, data: Array, _session_id: int) -> bool:
154154
"mcp:eval_error":
155155
_on_eval_error(data)
156156
return true
157+
"mcp:eval_ack":
158+
_on_eval_ack(data)
159+
return true
157160
"mcp:eval_compiled":
158161
_on_eval_compiled(data)
159162
return true
@@ -437,21 +440,12 @@ func _send_eval(
437440
_log_buffer.log("[debug] !! eval timeout (%s)" % request_id)
438441
timer.timeout.connect(timeout_callable)
439442

440-
## #490: arm the compile-grace timer. If mcp:eval_compiled hasn't set
441-
## `compiled` by the time it fires, the eval source failed to parse.
443+
## #490: arm the compile-grace timer. _on_eval_grace concludes a parse error
444+
## only when the game acked the eval (it received the message and started
445+
## reload()) but never sent mcp:eval_compiled — see there for why a missing
446+
## ack must NOT be read as a compile error.
442447
var grace: SceneTreeTimer = tree.create_timer(EVAL_COMPILE_GRACE_SEC)
443-
var grace_callable := func() -> void:
444-
var pending_entry = _pending.get(request_id)
445-
if pending_entry == null or pending_entry.get("compiled", false):
446-
return
447-
_clear_pending(request_id)
448-
var conn: McpConnection = pending_entry.connection
449-
if conn == null or not is_instance_valid(conn):
450-
return
451-
_send_error(conn, request_id, ErrorCodes.EVAL_COMPILE_ERROR,
452-
"Game eval failed to compile — likely a GDScript syntax/parse error. The parse error text is in the editor's Output/Debugger panel; it is not capturable from the running game. Check your eval code's syntax.")
453-
if _log_buffer:
454-
_log_buffer.log("[debug] !! eval compile error (%s)" % request_id)
448+
var grace_callable := func() -> void: _on_eval_grace(request_id)
455449
grace.timeout.connect(grace_callable)
456450

457451
_pending[request_id] = {
@@ -460,6 +454,7 @@ func _send_eval(
460454
"timeout_callable": timeout_callable,
461455
"grace_timer": grace,
462456
"grace_callable": grace_callable,
457+
"acked": false,
463458
"compiled": false,
464459
}
465460

@@ -512,6 +507,47 @@ func _on_eval_error(data: Array) -> void:
512507
_log_buffer.log("[debug] <- mcp:eval_error (%s): %s" % [request_id, message])
513508

514509

510+
## #490: the game sends this at the top of _handle_eval, BEFORE reload() (so it
511+
## survives a parse-error abort). It positively signals "the game received this
512+
## eval and started compiling it" — letting _on_eval_grace tell a real parse
513+
## error (acked, never compiled) apart from a message the game hasn't serviced
514+
## yet (never acked — main thread blocked by a long frame/load or a CPU-bound
515+
## prior eval).
516+
func _on_eval_ack(data: Array) -> void:
517+
if data.is_empty():
518+
return
519+
var request_id: String = data[0]
520+
var pending_entry = _pending.get(request_id)
521+
if pending_entry == null:
522+
return
523+
pending_entry["acked"] = true
524+
if _log_buffer:
525+
_log_buffer.log("[debug] <- mcp:eval_ack (%s)" % request_id)
526+
527+
528+
## #490: compile-grace timer fired. Conclude a parse error ONLY when the game
529+
## acked the eval (started reload()) but never sent mcp:eval_compiled. If it
530+
## never acked, the game simply hasn't serviced the message yet — NOT a parse
531+
## error — so leave _pending intact and let the normal eval timeout handle it
532+
## rather than false-failing a valid eval and dropping its eventual real reply.
533+
func _on_eval_grace(request_id: String) -> void:
534+
var pending_entry = _pending.get(request_id)
535+
if pending_entry == null or pending_entry.get("compiled", false):
536+
return
537+
if not pending_entry.get("acked", false):
538+
if _log_buffer:
539+
_log_buffer.log("[debug] eval grace: no ack yet, deferring to timeout (%s)" % request_id)
540+
return
541+
_clear_pending(request_id)
542+
var conn: McpConnection = pending_entry.connection
543+
if conn == null or not is_instance_valid(conn):
544+
return
545+
_send_error(conn, request_id, ErrorCodes.EVAL_COMPILE_ERROR,
546+
"Game eval failed to compile — likely a GDScript syntax/parse error. The parse error text is in the editor's Output/Debugger panel; it is not capturable from the running game. Check your eval code's syntax.")
547+
if _log_buffer:
548+
_log_buffer.log("[debug] !! eval compile error (%s)" % request_id)
549+
550+
515551
## #490: the game sends this the instant reload() of the eval source
516552
## succeeds. Flips the pending entry's `compiled` flag so the compile-grace
517553
## timer won't fire a false EVAL_COMPILE_ERROR.

plugin/addons/godot_ai/runtime/game_helper.gd

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,12 @@ func _handle_eval(data: Array) -> void:
593593

594594
var script: GDScript = GDScript.new()
595595
script.source_code = script_source
596+
## #490: ack BEFORE reload(). A parse error aborts this function at reload()
597+
## without a return code in a debug build, so this is our only chance to tell
598+
## the editor "received + about to compile." The editor uses that to tell a
599+
## real parse error (acked, never compiled) apart from a message it simply
600+
## hasn't serviced yet (never acked); see mcp_debugger_plugin._on_eval_grace.
601+
EngineDebugger.send_message("mcp:eval_ack", [request_id])
596602
## reload() ABORTS this function on a parse error in a debug build (it does
597603
## not return a non-OK code there), so the lines below only run when the
598604
## source compiled. Keep reload() INLINE — moving it behind a timer/await

test_project/tests/test_game_eval_errors.gd

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,53 @@ func test_clear_pending_disconnects_grace_and_probe_timers() -> void:
344344
"_clear_pending disconnects the compile-grace timer")
345345
assert_false(probe.timeout.is_connected(probe_cb),
346346
"_clear_pending disconnects the runtime probe timer")
347+
348+
349+
func test_on_eval_ack_sets_flag() -> void:
350+
var plugin := McpDebuggerPlugin.new()
351+
var rid := "rid-ack"
352+
plugin._pending[rid] = {"connection": null, "acked": false, "compiled": false}
353+
plugin._on_eval_ack([rid])
354+
assert_true(plugin._pending[rid]["acked"],
355+
"mcp:eval_ack flips the pending entry's acked flag")
356+
357+
358+
func test_eval_grace_fires_compile_error_when_acked_but_not_compiled() -> void:
359+
## The positive compile-failure signal: the game acked (started reload) but
360+
## never sent mcp:eval_compiled → the source failed to parse.
361+
var plugin := McpDebuggerPlugin.new()
362+
var conn := _StubConnection.new()
363+
var rid := "rid-grace-compile"
364+
plugin._pending[rid] = {"connection": conn, "acked": true, "compiled": false}
365+
plugin._on_eval_grace(rid)
366+
assert_false(plugin._pending.has(rid), "a confirmed compile error clears pending")
367+
assert_eq(conn.captured.size(), 1, "one deferred reply")
368+
assert_eq(conn.captured[0]["payload"]["error"]["code"], ErrorCodes.EVAL_COMPILE_ERROR,
369+
"replies with EVAL_COMPILE_ERROR")
370+
conn.free()
371+
372+
373+
func test_eval_grace_defers_when_not_acked() -> void:
374+
## The fix: a missing ack means the game hasn't serviced the eval yet (busy
375+
## main thread), NOT a parse error. Must NOT fire EVAL_COMPILE_ERROR and must
376+
## leave pending intact so the eventual real reply is still delivered.
377+
var plugin := McpDebuggerPlugin.new()
378+
var conn := _StubConnection.new()
379+
var rid := "rid-grace-noack"
380+
plugin._pending[rid] = {"connection": conn, "acked": false, "compiled": false}
381+
plugin._on_eval_grace(rid)
382+
assert_true(plugin._pending.has(rid),
383+
"an un-acked eval is left pending (deferred to the normal timeout)")
384+
assert_eq(conn.captured.size(), 0, "no false EVAL_COMPILE_ERROR is sent")
385+
conn.free()
386+
387+
388+
func test_eval_grace_noop_when_already_compiled() -> void:
389+
var plugin := McpDebuggerPlugin.new()
390+
var conn := _StubConnection.new()
391+
var rid := "rid-grace-compiled"
392+
plugin._pending[rid] = {"connection": conn, "acked": true, "compiled": true}
393+
plugin._on_eval_grace(rid)
394+
assert_true(plugin._pending.has(rid), "a compiled eval is untouched by the grace timer")
395+
assert_eq(conn.captured.size(), 0, "no compile error for a compiled eval")
396+
conn.free()

0 commit comments

Comments
 (0)