Files
tekton/addons/godot_ai/runtime/loggers/game_logger.gd
T

159 lines
6.3 KiB
GDScript

@tool
extends Logger
## Game-process Logger subclass.
##
## NOTE: deliberately no `class_name` — `extends Logger` requires the Logger
## class which Godot only exposes from 4.5+. This file lives in the
## `.gdignore`'d `runtime/loggers/` folder so Godot's editor filesystem scan
## skips it entirely — on Godot < 4.5 it is never parsed, so it emits no
## "Could not find base class Logger" error (it used to, before #475's
## follow-up). game_helper.gd builds it from source at runtime via
## `logger_loader.gd` and only calls OS.add_logger() after gating on
## ClassDB.class_exists("Logger"). Registered from inside the running game
## so we can intercept print(), printerr(), push_error(), and
## push_warning() and ferry them back to the editor over the
## EngineDebugger channel — the same bridge PR #76 uses for screenshots.
##
## Logger virtuals can be called from any thread (e.g. async loaders push
## errors off the main thread). We accumulate into _pending under a Mutex
## and the host (game_helper.gd) flushes once per frame from the main
## thread, where EngineDebugger.send_message is safe to call.
## `McpLogBacktrace` is published as a `class_name` on log_backtrace.gd, but a
## freshly-launched game subprocess (no prior editor scan; e.g. CI launching
## `--headless --path`) hits this autoload before the global class_name table
## is populated, and parsing this script fails with
## "Identifier 'McpLogBacktrace' not declared in the current scope". Using
## `const preload` resolves the path at parse time and is independent of the
## class_name registry — matches the project convention in CLAUDE.md
## ("Internals … skip class_name entirely and load via const preload").
const _LogBacktrace := preload("res://addons/godot_ai/utils/log_backtrace.gd")
var _pending: Array = []
var _mutex := Mutex.new()
## #490: a monotonic sequence + a small ring of recent GDScript runtime
## (script-type) errors, each with its text AND the function names in its
## backtrace. game_helper uses this to attribute a runtime error to the
## *specific* eval that raised it: each eval's wrapper has a uniquely named
## inner function, and game_helper asks find_script_error_since() whether any
## error past its pre-eval baseline carries that function in its stack. This
## avoids failing an eval on an unrelated background game error that merely
## advanced a global counter, and keeps overlapping evals from cross-
## attributing. Gated on ERROR_TYPE_SCRIPT (2) so push_error()/push_warning()
## (types 0/1) never count. Mutex-guarded: _log_error can fire from any thread.
const _ERROR_TYPE_SCRIPT := 2
const _MAX_RECENT_SCRIPT_ERRORS := 64
var _script_error_seq: int = 0
var _recent_script_errors: Array = []
func _log_message(message: String, error: bool) -> void:
## `error` is true for printerr(), false for print().
var level := "error" if error else "info"
_append(level, message)
func _log_error(
function: String,
file: String,
line: int,
code: String,
rationale: String,
_editor_notify: bool,
error_type: int,
script_backtraces: Array,
) -> void:
## EngineDebugger's payload shape is `[level, text]` — the source
## location has nowhere structured to land for the game side, so we
## inline it into `text`. editor_logger keeps the resolved fields
## as structured columns instead.
var resolved := _LogBacktrace.resolve_error(
function, file, line, code, rationale, error_type, script_backtraces,
)
var loc := ""
if not resolved.path.is_empty():
loc = "%s:%d @ %s" % [resolved.path, resolved.line, resolved.function] if not resolved.function.is_empty() else "%s:%d" % [resolved.path, resolved.line]
var text: String = "%s (%s)" % [resolved.message, loc] if not loc.is_empty() else resolved.message
var details: Dictionary = resolved.get("details", {})
_append(resolved.level, text, details)
if error_type == _ERROR_TYPE_SCRIPT:
## Collect every function name in the first non-empty backtrace so
## game_helper can match its eval's uniquely named wrapper function.
var funcs := PackedStringArray()
for bt: RefCounted in script_backtraces:
if bt != null and bt.get_frame_count() > 0:
for i: int in bt.get_frame_count():
funcs.append(bt.get_frame_function(i))
break
_mutex.lock()
_script_error_seq += 1
_recent_script_errors.append({"seq": _script_error_seq, "text": text, "funcs": funcs})
if _recent_script_errors.size() > _MAX_RECENT_SCRIPT_ERRORS:
_recent_script_errors.remove_at(0)
_mutex.unlock()
func _append(level: String, text: String, details: Dictionary = {}) -> void:
_mutex.lock()
if details.is_empty():
_pending.append([level, text])
else:
_pending.append([level, text, details.duplicate(true)])
_mutex.unlock()
## Drain the pending queue and return entries as [[level, text], ...].
## Called from the main thread by game_helper each frame.
func drain() -> Array:
_mutex.lock()
var out := _pending
_pending = []
_mutex.unlock()
return out
func has_pending() -> bool:
_mutex.lock()
var any := not _pending.is_empty()
_mutex.unlock()
return any
## #490: monotonic count of script-type runtime errors seen this run.
## game_helper snapshots this before an eval to use as the `since_seq`
## baseline for find_script_error_since(). Mutex-guarded.
func script_error_seq() -> int:
_mutex.lock()
var v := _script_error_seq
_mutex.unlock()
return v
## #490: text (with inlined path:line @ function) of the most recent
## script-type runtime error, or "" if none seen this run.
func last_script_error_text() -> String:
_mutex.lock()
var v: String = _recent_script_errors[-1]["text"] if not _recent_script_errors.is_empty() else ""
_mutex.unlock()
return v
## #490: text of the most recent script error with seq > since_seq whose
## backtrace includes `function_name`, or "" if none. Lets game_helper
## attribute a runtime error to the exact eval whose uniquely named wrapper
## function appears in the stack — ignoring unrelated game errors and errors
## from before the eval started. Mutex-guarded.
func find_script_error_since(since_seq: int, function_name: String) -> String:
_mutex.lock()
var found := ""
for i in range(_recent_script_errors.size() - 1, -1, -1):
var rec: Dictionary = _recent_script_errors[i]
if int(rec["seq"]) <= since_seq:
break
if (rec["funcs"] as PackedStringArray).has(function_name):
found = rec["text"]
break
_mutex.unlock()
return found