967 lines
34 KiB
GDScript
967 lines
34 KiB
GDScript
@tool
|
|
extends Node
|
|
|
|
## Plugin-lifetime owner for every background client-configuration job.
|
|
##
|
|
## The Dock is replaceable UI. Threads cannot share that lifetime: a timed-out
|
|
## probe or package pre-warm can outlive the panel that requested it, and
|
|
## destroying a live Godot Thread corrupts the GDScript VM. This node therefore
|
|
## owns both worker pools, their cancellation, phases, and one action slot per
|
|
## client until its Thread has been joined. It publishes copied value snapshots
|
|
## and outcomes; it never retains the Dock, plugin, lifecycle, or update manager.
|
|
|
|
const Client := preload("res://addons/godot_ai/clients/_base.gd")
|
|
const ClientConfigurator := preload("res://addons/godot_ai/client_configurator.gd")
|
|
const ClientRegistry := preload("res://addons/godot_ai/clients/_registry.gd")
|
|
const JsonStrategy := preload("res://addons/godot_ai/clients/_json_strategy.gd")
|
|
const TomlStrategy := preload("res://addons/godot_ai/clients/_toml_strategy.gd")
|
|
const DshStrategy := preload("res://addons/godot_ai/clients/_dsh_strategy.gd")
|
|
const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd")
|
|
const RefreshState := preload("res://addons/godot_ai/utils/mcp_client_refresh_state.gd")
|
|
const MutationLock := preload("res://addons/godot_ai/utils/client_mutation_lock.gd")
|
|
|
|
const STATUS_COOLDOWN_MSEC := 15 * 1000
|
|
const STATUS_TIMEOUT_MSEC := 30 * 1000
|
|
## One scoped CLI configure may run three bounded cleanup commands, one write,
|
|
## discovery, and read-back verification before it reaches the optional package
|
|
## prewarm. Keep the owner watchdog above that real mutation budget.
|
|
const ACTION_TIMEOUT_MSEC := 75 * 1000
|
|
const MCP_ACTION_DEFERRED_GRACE_MSEC := 5 * 1000
|
|
const MAX_MCP_STATUS_WAITERS := 64
|
|
const PHASE_PREWARM := "prewarm"
|
|
const MUTATION_UNPROVEN_META := &"_godot_ai_client_mutation_termination_unproven"
|
|
const POST_UPDATE_UNPROVEN_META := &"_godot_ai_post_update_termination_unproven"
|
|
|
|
signal snapshot_changed(snapshot: Dictionary)
|
|
signal status_refresh_completed(results: Dictionary)
|
|
signal mcp_status_completed(request_ids: Array[String], payload: Dictionary)
|
|
signal mcp_action_completed(request_id: String, payload: Dictionary)
|
|
signal post_update_repin_completed(result: Dictionary)
|
|
signal action_completed(client_id: String, action: String, result: Dictionary, prewarm: Dictionary)
|
|
signal action_timed_out(client_id: String, action: String, detail: String)
|
|
|
|
var _accepting_work := false
|
|
var _client_health_blocked := false
|
|
var _client_ids: Array[String] = []
|
|
|
|
var _refresh_thread: Thread
|
|
var _refresh_state := RefreshState.IDLE
|
|
var _refresh_pending := false
|
|
var _refresh_pending_force := false
|
|
var _refresh_completed_msec := 0
|
|
var _refresh_started_msec := 0
|
|
var _refresh_generation := 0
|
|
## request_id -> first refresh generation allowed to answer it. An MCP request
|
|
## arriving behind a partial Dock refresh waits for the coalesced all-client
|
|
## retry instead of accepting the partial result.
|
|
var _mcp_status_waiters: Dictionary = {}
|
|
|
|
var _action_threads: Dictionary = {}
|
|
var _action_started_msec: Dictionary = {}
|
|
var _action_names: Dictionary = {}
|
|
var _action_request_ids: Dictionary = {}
|
|
var _action_timeout_reported: Dictionary = {}
|
|
var _action_phase_mutex := Mutex.new()
|
|
var _action_phases: Dictionary = {}
|
|
var _action_cancel_mutex := Mutex.new()
|
|
var _action_cancelled: Dictionary = {}
|
|
## client_id -> action whose CLI termination could not be proven. Mirrored into
|
|
## process-lifetime Engine metadata for immediate reload safety; the CLI
|
|
## strategy's global lock directory is the durable cross-restart authority.
|
|
var _mutation_termination_unproven: Dictionary = {}
|
|
## A successful update enters one pre-activation migration job. It is owned and
|
|
## joined here like every other client worker, while ordinary Dock/MCP work
|
|
## remains closed and the server lifecycle stays dormant.
|
|
var _post_update_thread: Thread
|
|
var _post_update_generation := 0
|
|
var _post_update_cancel_mutex := Mutex.new()
|
|
var _post_update_cancelled := false
|
|
var _post_update_termination_unproven := false
|
|
var _last_snapshot: Dictionary = {}
|
|
|
|
|
|
func _init() -> void:
|
|
var mutations: Variant = Engine.get_meta(MUTATION_UNPROVEN_META, {})
|
|
if mutations is Dictionary:
|
|
_mutation_termination_unproven = (mutations as Dictionary).duplicate(true)
|
|
_post_update_termination_unproven = bool(
|
|
Engine.get_meta(POST_UPDATE_UNPROVEN_META, false)
|
|
)
|
|
|
|
|
|
func _ready() -> void:
|
|
## Work can start — and the owner can be activated — before this node is
|
|
## ready. The composition root adds this child, wires it, and calls
|
|
## `activate()` inside the plugin's `_enter_tree`; Godot defers a child's
|
|
## `_ready` until that returns, so this callback runs LAST, after both the
|
|
## post-update migration thread and the ordinary activation seam.
|
|
##
|
|
## Scripts that define `_process` are processing by default, so an inert
|
|
## owner still has to turn it off here. What it must never do is turn it
|
|
## off again once work has been admitted: `_process` is the only thing that
|
|
## joins finished workers, so disabling it after `activate()` strands every
|
|
## refresh and client action for the rest of the session — the sweep never
|
|
## leaves RUNNING, Configure never leaves its last published phase.
|
|
set_process(_accepting_work or _has_work_in_flight())
|
|
|
|
|
|
func _has_work_in_flight() -> bool:
|
|
return _post_update_thread != null or _refresh_thread != null or not _action_threads.is_empty()
|
|
|
|
|
|
## Construction is inert. The composition root calls activate only after its
|
|
## startup barrier has allowed normal client work.
|
|
func activate() -> void:
|
|
if _refresh_state == RefreshState.SHUTTING_DOWN or _post_update_thread != null:
|
|
return
|
|
_accepting_work = true
|
|
set_process(true)
|
|
_publish_snapshot()
|
|
_retry_deferred_refresh()
|
|
|
|
|
|
func set_client_health_blocked(blocked: bool) -> void:
|
|
_client_health_blocked = blocked
|
|
if not blocked:
|
|
_retry_deferred_refresh()
|
|
|
|
|
|
func snapshot() -> Dictionary:
|
|
var busy: Array[String] = []
|
|
for client_id in _action_threads:
|
|
busy.append(String(client_id))
|
|
busy.sort()
|
|
return {
|
|
"accepting_work": _accepting_work,
|
|
"refresh_state": _refresh_state,
|
|
"refresh_completed": _refresh_completed_msec > 0,
|
|
"post_update_repin_running": _post_update_thread != null,
|
|
"busy_actions": busy,
|
|
"action_names": _action_names.duplicate(true),
|
|
"action_phases": _read_action_phases(),
|
|
}
|
|
|
|
|
|
func _publish_snapshot() -> void:
|
|
var value := snapshot()
|
|
if value == _last_snapshot:
|
|
return
|
|
_last_snapshot = value.duplicate(true)
|
|
snapshot_changed.emit(value)
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
_poll_post_update_repin()
|
|
_poll_refresh()
|
|
_poll_actions()
|
|
_check_refresh_timeout()
|
|
_check_action_timeouts()
|
|
_retry_deferred_refresh()
|
|
|
|
|
|
## Stop accepting work, cooperatively cancel action subprocesses, then realize
|
|
## every Thread before scripts can be overwritten. Godot exposes no timed join;
|
|
## deadline_msec is reported honestly rather than pretending the join can be
|
|
## interrupted without risking VM corruption.
|
|
func quiesce(deadline_msec: int = 0) -> Dictionary:
|
|
_accepting_work = false
|
|
_refresh_state = RefreshState.SHUTTING_DOWN
|
|
_publish_snapshot()
|
|
for client_id in _action_threads:
|
|
_set_action_cancelled(String(client_id), true)
|
|
_set_post_update_cancelled(true)
|
|
_refresh_generation += 1
|
|
if _refresh_thread != null:
|
|
_refresh_thread.wait_to_finish()
|
|
_refresh_thread = null
|
|
if _post_update_thread != null:
|
|
_record_unproven_post_update_result(_post_update_thread.wait_to_finish())
|
|
_post_update_thread = null
|
|
for thread in _action_threads.values():
|
|
if thread != null:
|
|
_record_unproven_action_payload((thread as Thread).wait_to_finish())
|
|
_action_threads.clear()
|
|
_action_started_msec.clear()
|
|
_action_names.clear()
|
|
_action_request_ids.clear()
|
|
_action_timeout_reported.clear()
|
|
_action_phase_mutex.lock()
|
|
_action_phases.clear()
|
|
_action_phase_mutex.unlock()
|
|
_action_cancel_mutex.lock()
|
|
_action_cancelled.clear()
|
|
_action_cancel_mutex.unlock()
|
|
_refresh_pending = false
|
|
_refresh_pending_force = false
|
|
_mcp_status_waiters.clear()
|
|
set_process(false)
|
|
_publish_snapshot()
|
|
var termination_unproven := (
|
|
_post_update_termination_unproven
|
|
or not _mutation_termination_unproven.is_empty()
|
|
or MutationLock.is_locked()
|
|
)
|
|
return {
|
|
"ok": not termination_unproven,
|
|
"deadline_exceeded": deadline_msec > 0 and Time.get_ticks_msec() > deadline_msec,
|
|
"termination_unproven": termination_unproven,
|
|
}
|
|
|
|
|
|
## Re-open the same owner after a pre-activation update failure. Successful
|
|
## activation tears down the plugin instead, so this is only a bail-out path.
|
|
func resume_after_quiesce() -> void:
|
|
if _refresh_state != RefreshState.SHUTTING_DOWN:
|
|
return
|
|
_refresh_state = RefreshState.IDLE
|
|
_set_post_update_cancelled(false)
|
|
activate()
|
|
|
|
|
|
## Start the hard post-update client barrier. The signed transaction outcome
|
|
## supplies both versions; a mismatch with the code actually loaded fails
|
|
## closed before touching client config or starting the server.
|
|
func begin_post_update_repin(
|
|
from_version: String, to_version: String, replace_owned_mismatches: bool = false
|
|
) -> Dictionary:
|
|
## `from_version` is empty when the closed-editor installer put v4 into a
|
|
## project that had no add-on: nothing was replaced, and owned client
|
|
## entries still repin to the installed version on this first start.
|
|
var replaced := from_version.strip_edges()
|
|
var installed := to_version.strip_edges()
|
|
if installed.is_empty():
|
|
return {"ok": false, "error": "Post-update target version is missing."}
|
|
if installed != ClientConfigurator.get_plugin_version():
|
|
return {"ok": false, "error": "Post-update target does not match the loaded plugin."}
|
|
if MutationLock.is_locked():
|
|
return {"ok": false, "error": MutationLock.recovery_message()}
|
|
if _post_update_termination_unproven or not _mutation_termination_unproven.is_empty():
|
|
return {"ok": false, "error": MutationLock.recovery_message()}
|
|
if (
|
|
_accepting_work
|
|
or _post_update_thread != null
|
|
or _refresh_thread != null
|
|
or not _action_threads.is_empty()
|
|
or _refresh_state == RefreshState.SHUTTING_DOWN
|
|
):
|
|
return {"ok": false, "error": "Client work is not idle for post-update migration."}
|
|
ClientConfigurator.warm_env_snapshot()
|
|
_warm_worker_bytecode()
|
|
var probes: Array[Dictionary] = []
|
|
for client_id in ClientConfigurator.client_ids():
|
|
var probe := ClientConfigurator.client_status_probe_snapshot(client_id)
|
|
## Manual-only descriptors remain visible in ordinary status UI, but M6
|
|
## cannot safely parse or rewrite them. In particular Zed owns JSONC and
|
|
## may contain comments that the strict shared JSON parser must reject.
|
|
## Keep manual remediation outside the hard startup barrier.
|
|
if not probe.is_empty() and bool(probe.get("automatic_config_edits", true)):
|
|
probes.append(probe)
|
|
var context := ClientConfigurator.capture_launch_context()
|
|
if context.is_empty():
|
|
return {"ok": false, "error": "Post-update launch context is unavailable."}
|
|
_set_post_update_cancelled(false)
|
|
_post_update_generation += 1
|
|
_post_update_thread = Thread.new()
|
|
var start_error := _post_update_thread.start(
|
|
Callable(self, "_run_post_update_repin").bind(
|
|
probes,
|
|
context,
|
|
replaced,
|
|
installed,
|
|
replace_owned_mismatches,
|
|
_post_update_generation,
|
|
)
|
|
)
|
|
if start_error != OK:
|
|
_post_update_thread = null
|
|
return {"ok": false, "error": "Could not start post-update client migration."}
|
|
set_process(true)
|
|
_publish_snapshot()
|
|
return {"ok": true}
|
|
|
|
|
|
func _run_post_update_repin(
|
|
probes: Array[Dictionary],
|
|
context: Dictionary,
|
|
from_version: String,
|
|
to_version: String,
|
|
replace_owned_mismatches: bool,
|
|
generation: int,
|
|
) -> Dictionary:
|
|
var prewarm := ClientConfigurator.prewarm_server_package_blocking(
|
|
to_version,
|
|
ClientConfigurator.PREWARM_TIMEOUT_MS,
|
|
Callable(self, "_is_post_update_cancelled"),
|
|
)
|
|
var configured_ids: Array[String] = []
|
|
var repinned_ids: Array[String] = []
|
|
var foreign_ids: Array[String] = []
|
|
var deferred: Array[Dictionary] = []
|
|
if bool(prewarm.get("termination_failed", false)):
|
|
return _post_update_result(
|
|
false,
|
|
generation,
|
|
configured_ids,
|
|
repinned_ids,
|
|
"Post-update package pre-warm could not be proven stopped; stop relevant uvx processes or reboot before retrying.",
|
|
prewarm,
|
|
true,
|
|
)
|
|
var server_url := ClientConfigurator.server_url_from(context)
|
|
var resolved_launch := ClientConfigurator.resolve_attach_launch(context)
|
|
for probe in probes:
|
|
if _is_post_update_cancelled():
|
|
return _post_update_result(false, generation, configured_ids, repinned_ids,
|
|
"Post-update client migration was cancelled.", prewarm)
|
|
var client_id := String(probe.get("id", ""))
|
|
var details := ClientConfigurator.check_status_details_for_url_with_cli_path(
|
|
client_id,
|
|
server_url,
|
|
String(probe.get("cli_path", "")),
|
|
context,
|
|
resolved_launch,
|
|
)
|
|
var status := int(details.get("status", Client.Status.ERROR))
|
|
if status == Client.Status.NOT_CONFIGURED:
|
|
continue
|
|
if status == Client.Status.ERROR:
|
|
## One client whose configuration cannot even be read must not
|
|
## hold every other client and the server itself hostage: leave
|
|
## it for the dock's Configure and say so.
|
|
deferred.append({
|
|
"id": client_id,
|
|
"reason": "its configuration could not be read: %s"
|
|
% str(details.get("error_msg", "unknown error")),
|
|
})
|
|
continue
|
|
if status == Client.Status.CONFIGURED:
|
|
configured_ids.append(client_id)
|
|
continue
|
|
if replace_owned_mismatches:
|
|
## A major migration rewrites only entries that launch Godot AI.
|
|
## An entry under our name that starts something else is the
|
|
## user's own server: leave it, name it, let Configure replace it.
|
|
if not bool(details.get("owned", false)):
|
|
foreign_ids.append(client_id)
|
|
continue
|
|
elif not ClientConfigurator.entry_drift_is_version_pin_only(
|
|
client_id, from_version, context
|
|
):
|
|
## #890's write restriction stands: an entry that is not provably
|
|
## what Configure wrote before the update is never rewritten here.
|
|
## But refusing the write is not a reason to refuse the server
|
|
## (#999): the entry is left untouched, named, and deferred to
|
|
## the dock's Configure, and startup continues.
|
|
deferred.append({
|
|
"id": client_id,
|
|
"reason": "its godot-ai entry differs from what Configure wrote before the update",
|
|
})
|
|
continue
|
|
var configured := ClientConfigurator.configure(client_id, server_url, context)
|
|
if str(configured.get("status", "error")) != "ok":
|
|
return _post_update_result(false, generation, configured_ids, repinned_ids,
|
|
"%s repin failed: %s" % [client_id, str(configured.get("message", "unknown error"))],
|
|
prewarm,
|
|
bool(configured.get("termination_failed", false)),
|
|
client_id)
|
|
var verified := ClientConfigurator.check_status_details_for_url_with_cli_path(
|
|
client_id,
|
|
server_url,
|
|
String(probe.get("cli_path", "")),
|
|
context,
|
|
resolved_launch,
|
|
)
|
|
if int(verified.get("status", Client.Status.ERROR)) != Client.Status.CONFIGURED:
|
|
return _post_update_result(false, generation, configured_ids, repinned_ids,
|
|
"%s repin could not be verified." % client_id, prewarm)
|
|
configured_ids.append(client_id)
|
|
repinned_ids.append(client_id)
|
|
configured_ids.sort()
|
|
repinned_ids.sort()
|
|
foreign_ids.sort()
|
|
deferred.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return str(a.id) < str(b.id))
|
|
return _post_update_result(
|
|
true, generation, configured_ids, repinned_ids, "", prewarm, false, "", foreign_ids, deferred
|
|
)
|
|
|
|
|
|
static func _post_update_result(
|
|
ok: bool,
|
|
generation: int,
|
|
configured_ids: Array[String],
|
|
repinned_ids: Array[String],
|
|
error: String,
|
|
prewarm: Dictionary,
|
|
termination_failed: bool = false,
|
|
unsafe_client_id: String = "",
|
|
foreign_ids: Array[String] = [],
|
|
deferred: Array[Dictionary] = [],
|
|
) -> Dictionary:
|
|
return {
|
|
"ok": ok,
|
|
"generation": generation,
|
|
"configured_ids": configured_ids.duplicate(),
|
|
"repinned_ids": repinned_ids.duplicate(),
|
|
"error": error,
|
|
"prewarm": prewarm.duplicate(true),
|
|
"termination_failed": termination_failed,
|
|
"unsafe_client_id": unsafe_client_id,
|
|
## Entries under our name that launch something else; left unchanged.
|
|
"foreign_ids": foreign_ids.duplicate(),
|
|
## `{id, reason}` for entries the migration left unchanged because
|
|
## they were unreadable or drifted beyond the version pin (#999).
|
|
## They are the dock's Configure job, not a startup barrier.
|
|
"deferred": deferred.duplicate(true),
|
|
}
|
|
|
|
|
|
func _poll_post_update_repin() -> void:
|
|
if _post_update_thread == null or _post_update_thread.is_alive():
|
|
return
|
|
var payload: Variant = _post_update_thread.wait_to_finish()
|
|
_post_update_thread = null
|
|
if _refresh_state == RefreshState.SHUTTING_DOWN:
|
|
return
|
|
var result: Dictionary = (
|
|
(payload as Dictionary).duplicate(true)
|
|
if payload is Dictionary
|
|
else {"ok": false, "error": "Post-update client migration returned no result."}
|
|
)
|
|
_record_unproven_post_update_result(result)
|
|
if int(result.get("generation", -1)) != _post_update_generation:
|
|
return
|
|
post_update_repin_completed.emit(result)
|
|
_publish_snapshot()
|
|
|
|
|
|
func request_action(client_id: String, action: String) -> bool:
|
|
var started := _start_action(client_id, action, "")
|
|
if not bool(started.get("ok", false)) and bool(started.get("termination_failed", false)):
|
|
action_completed.emit(
|
|
client_id,
|
|
action,
|
|
{
|
|
"status": "error",
|
|
"message": str(started.get("error", "Client action is unavailable.")),
|
|
"termination_failed": true,
|
|
},
|
|
{},
|
|
)
|
|
return bool(started.get("ok", false))
|
|
|
|
|
|
## MCP mutations use the same one-slot owner as Dock actions. They preserve the
|
|
## tool's previous mutation-only behavior: package prewarm remains a Dock-only
|
|
## convenience. The returned timeout is the bounded out-of-band reply budget.
|
|
func request_mcp_action(request_id: String, client_id: String, action: String) -> Dictionary:
|
|
if request_id.is_empty():
|
|
return {"ok": false, "error": "Client mutation requires a deferred request context."}
|
|
return _start_action(client_id, action, request_id)
|
|
|
|
|
|
func _start_action(client_id: String, action: String, request_id: String) -> Dictionary:
|
|
if not _accepting_work:
|
|
return {"ok": false, "error": "Client worker is unavailable."}
|
|
if action not in ["configure", "remove"]:
|
|
return {"ok": false, "error": "Unknown client action: %s" % action}
|
|
if _mutation_termination_unproven.has(client_id):
|
|
return {
|
|
"ok": false,
|
|
"error": MutationLock.recovery_message(),
|
|
"termination_failed": true,
|
|
}
|
|
if _action_threads.has(client_id):
|
|
return {"ok": false, "error": "A client action is already running for %s." % client_id}
|
|
if not ClientConfigurator.has_client(client_id):
|
|
return {"ok": false, "error": "Unknown client: %s" % client_id}
|
|
ClientConfigurator.warm_env_snapshot()
|
|
_set_action_cancelled(client_id, false)
|
|
_clear_action_phase(client_id)
|
|
var context := ClientConfigurator.capture_launch_context()
|
|
_action_names[client_id] = action
|
|
_action_request_ids[client_id] = request_id
|
|
_action_timeout_reported.erase(client_id)
|
|
_action_started_msec[client_id] = Time.get_ticks_msec()
|
|
var thread := Thread.new()
|
|
_action_threads[client_id] = thread
|
|
var error := thread.start(
|
|
Callable(self, "_run_action").bind(
|
|
client_id,
|
|
action,
|
|
ClientConfigurator.server_url_from(context),
|
|
context,
|
|
request_id.is_empty(),
|
|
)
|
|
)
|
|
if error != OK:
|
|
_action_threads.erase(client_id)
|
|
_action_started_msec.erase(client_id)
|
|
_action_names.erase(client_id)
|
|
_action_request_ids.erase(client_id)
|
|
return {"ok": false, "error": "Could not start client worker thread."}
|
|
## Same invariant `begin_post_update_repin` states: a live Thread is only
|
|
## ever joined from `_process`, so spawning one must guarantee the poll.
|
|
set_process(true)
|
|
_publish_snapshot()
|
|
return {
|
|
"ok": true,
|
|
"deferred_timeout_ms": _mcp_action_deferred_timeout_msec(action),
|
|
}
|
|
|
|
|
|
func _run_action(
|
|
client_id: String,
|
|
action: String,
|
|
server_url: String,
|
|
context: Dictionary,
|
|
prewarm_after_configure: bool,
|
|
) -> Dictionary:
|
|
var result: Dictionary
|
|
var prewarm: Dictionary = {}
|
|
if action == "remove":
|
|
result = ClientConfigurator.remove(client_id, server_url, context)
|
|
else:
|
|
result = ClientConfigurator.configure(client_id, server_url, context)
|
|
if result.get("status") == "ok" and prewarm_after_configure:
|
|
_set_action_phase(client_id, PHASE_PREWARM)
|
|
prewarm = ClientConfigurator.prewarm_attach_launch(
|
|
context,
|
|
ClientConfigurator.PREWARM_TIMEOUT_MS,
|
|
{},
|
|
Callable(self, "_is_action_cancelled").bind(client_id),
|
|
)
|
|
return {
|
|
"client_id": client_id,
|
|
"action": action,
|
|
"result": result,
|
|
"prewarm": prewarm,
|
|
}
|
|
|
|
|
|
func _poll_actions() -> void:
|
|
var phase_changed := false
|
|
for client_id in _action_threads.keys():
|
|
var thread: Thread = _action_threads[client_id]
|
|
if thread == null:
|
|
continue
|
|
if thread.is_alive():
|
|
phase_changed = phase_changed or not _read_action_phase(String(client_id)).is_empty()
|
|
continue
|
|
var payload: Variant = thread.wait_to_finish()
|
|
_action_threads[client_id] = null
|
|
var action := String(_action_names.get(client_id, "configure"))
|
|
var result: Dictionary = {"status": "error", "message": "worker returned no result"}
|
|
var prewarm: Dictionary = {}
|
|
if payload is Dictionary:
|
|
var data := payload as Dictionary
|
|
action = String(data.get("action", action))
|
|
result = (data.get("result", {}) as Dictionary).duplicate(true)
|
|
prewarm = (data.get("prewarm", {}) as Dictionary).duplicate(true)
|
|
_finalize_action(String(client_id), action, result, prewarm)
|
|
if phase_changed:
|
|
_publish_snapshot()
|
|
|
|
|
|
func _finalize_action(
|
|
client_id: String,
|
|
action: String,
|
|
result: Dictionary,
|
|
prewarm: Dictionary,
|
|
) -> void:
|
|
## Safety evidence is consumed before the sole slot is released. A completed
|
|
## timed-out worker therefore cannot reopen a race window between is_alive()
|
|
## flipping false and its termination_failed payload being persisted.
|
|
_record_unproven_action_result(client_id, action, result, prewarm)
|
|
var request_id := String(_action_request_ids.get(client_id, ""))
|
|
_action_threads.erase(client_id)
|
|
_action_started_msec.erase(client_id)
|
|
_action_names.erase(client_id)
|
|
_action_request_ids.erase(client_id)
|
|
_action_timeout_reported.erase(client_id)
|
|
_set_action_cancelled(client_id, false)
|
|
_clear_action_phase(client_id)
|
|
if _refresh_state != RefreshState.SHUTTING_DOWN:
|
|
action_completed.emit(client_id, action, result.duplicate(true), prewarm.duplicate(true))
|
|
if not request_id.is_empty():
|
|
mcp_action_completed.emit(
|
|
request_id,
|
|
_mcp_action_payload(client_id, result),
|
|
)
|
|
_publish_snapshot()
|
|
|
|
|
|
static func _mcp_action_payload(client_id: String, result: Dictionary) -> Dictionary:
|
|
if str(result.get("status", "error")) == "error":
|
|
return McpErrorCodes.make(
|
|
McpErrorCodes.INTERNAL_ERROR,
|
|
str(result.get("message", "Configuration failed for '%s'" % client_id)),
|
|
)
|
|
return {"data": result.duplicate(true)}
|
|
|
|
|
|
static func _mcp_action_deferred_timeout_msec(_action: String) -> int:
|
|
return ACTION_TIMEOUT_MSEC + MCP_ACTION_DEFERRED_GRACE_MSEC
|
|
|
|
|
|
func _record_unproven_action_payload(payload: Variant) -> void:
|
|
if not payload is Dictionary:
|
|
return
|
|
var data := payload as Dictionary
|
|
var result: Dictionary = {}
|
|
var prewarm: Dictionary = {}
|
|
if data.get("result", null) is Dictionary:
|
|
result = data.get("result", {}) as Dictionary
|
|
if data.get("prewarm", null) is Dictionary:
|
|
prewarm = data.get("prewarm", {}) as Dictionary
|
|
_record_unproven_action_result(
|
|
str(data.get("client_id", "")),
|
|
str(data.get("action", "configure")),
|
|
result,
|
|
prewarm,
|
|
)
|
|
|
|
|
|
func _record_unproven_action_result(
|
|
client_id: String,
|
|
action: String,
|
|
result: Dictionary,
|
|
prewarm: Dictionary,
|
|
) -> void:
|
|
if client_id.is_empty():
|
|
return
|
|
if (
|
|
bool(result.get("termination_failed", false))
|
|
or bool(prewarm.get("termination_failed", false))
|
|
):
|
|
_mark_mutation_termination_unproven(client_id, action)
|
|
|
|
|
|
func _record_unproven_post_update_result(payload: Variant) -> void:
|
|
if not payload is Dictionary:
|
|
return
|
|
var result := payload as Dictionary
|
|
if not bool(result.get("termination_failed", false)):
|
|
return
|
|
var client_id := str(result.get("unsafe_client_id", ""))
|
|
if client_id.is_empty():
|
|
_post_update_termination_unproven = true
|
|
Engine.set_meta(POST_UPDATE_UNPROVEN_META, true)
|
|
else:
|
|
_mark_mutation_termination_unproven(client_id, "post_update_repin")
|
|
|
|
|
|
func _mark_mutation_termination_unproven(client_id: String, action: String) -> void:
|
|
_mutation_termination_unproven[client_id] = action
|
|
Engine.set_meta(
|
|
MUTATION_UNPROVEN_META,
|
|
_mutation_termination_unproven.duplicate(true),
|
|
)
|
|
|
|
|
|
func _check_action_timeouts(now_msec: int = -1) -> void:
|
|
var now := Time.get_ticks_msec() if now_msec < 0 else now_msec
|
|
for client_id in _action_threads.keys():
|
|
if bool(_action_timeout_reported.get(client_id, false)):
|
|
continue
|
|
var started := int(_action_started_msec.get(client_id, 0))
|
|
if started > 0 and now - started >= _action_budget(String(client_id)):
|
|
_report_action_timeout(String(client_id), now - started)
|
|
|
|
|
|
func _action_budget(client_id: String) -> int:
|
|
if _read_action_phase(client_id) == PHASE_PREWARM:
|
|
return ACTION_TIMEOUT_MSEC + ClientConfigurator.PREWARM_TIMEOUT_MS
|
|
return ACTION_TIMEOUT_MSEC
|
|
|
|
|
|
func _report_action_timeout(client_id: String, elapsed_msec: int) -> void:
|
|
if not _action_threads.has(client_id):
|
|
return
|
|
var thread: Thread = _action_threads[client_id]
|
|
var alive := thread != null and thread.is_alive()
|
|
if alive:
|
|
_set_action_cancelled(client_id, true)
|
|
_action_timeout_reported[client_id] = true
|
|
var action := String(_action_names.get(client_id, "configure"))
|
|
var label := "Remove" if action == "remove" else "Configure"
|
|
var detail := (
|
|
"%s is taking longer than expected and is being stopped; this row remains locked until its worker is joined." % label
|
|
if alive
|
|
else "%s did not report completion in time; waiting to inspect its result." % label
|
|
)
|
|
print(
|
|
"MCP | client action timed out: client=%s action=%s elapsed_ms=%d worker_alive=%s"
|
|
% [client_id, action, elapsed_msec, str(alive)]
|
|
)
|
|
action_timed_out.emit(client_id, action, detail)
|
|
_publish_snapshot()
|
|
|
|
|
|
func request_status_refresh(ids: Array[String], force := false) -> bool:
|
|
_client_ids = ids.duplicate()
|
|
if not _accepting_work or _client_health_blocked:
|
|
return false
|
|
if RefreshState.has_worker_alive(_refresh_state):
|
|
## One refresh worker total. A timed-out probe remains visible and owns
|
|
## the slot until its bounded subprocess calls return; repeated force
|
|
## requests collapse into one retry instead of growing live Threads.
|
|
_refresh_pending = true
|
|
_refresh_pending_force = _refresh_pending_force or force
|
|
_publish_snapshot()
|
|
return false
|
|
if RefreshState.is_blocked_for_spawn(_refresh_state):
|
|
return false
|
|
if not force and _refresh_completed_msec > 0:
|
|
if Time.get_ticks_msec() - _refresh_completed_msec < STATUS_COOLDOWN_MSEC:
|
|
return false
|
|
if ids.is_empty():
|
|
return false
|
|
if _filesystem_busy():
|
|
_refresh_state = RefreshState.DEFERRED_FOR_FILESYSTEM
|
|
_refresh_pending_force = _refresh_pending_force or force
|
|
_publish_snapshot()
|
|
return false
|
|
if force:
|
|
ClientConfigurator.invalidate_cli_cache()
|
|
_warm_worker_bytecode()
|
|
var probes: Array[Dictionary] = []
|
|
for client_id in ids:
|
|
var probe := ClientConfigurator.client_status_probe_snapshot(client_id)
|
|
if not probe.is_empty():
|
|
probes.append(probe)
|
|
if probes.is_empty():
|
|
_finish_refresh({})
|
|
return false
|
|
var context := ClientConfigurator.capture_launch_context()
|
|
_refresh_state = RefreshState.RUNNING
|
|
_refresh_pending = false
|
|
_refresh_pending_force = false
|
|
_refresh_started_msec = Time.get_ticks_msec()
|
|
_refresh_generation += 1
|
|
_refresh_thread = Thread.new()
|
|
var error := _refresh_thread.start(
|
|
Callable(self, "_run_status_refresh").bind(
|
|
probes,
|
|
ClientConfigurator.server_url_from(context),
|
|
context,
|
|
_refresh_generation,
|
|
)
|
|
)
|
|
if error != OK:
|
|
_refresh_thread = null
|
|
_refresh_state = RefreshState.IDLE
|
|
_publish_snapshot()
|
|
return false
|
|
## See `_start_action`: the sweep leaves RUNNING only through `_poll_refresh`.
|
|
set_process(true)
|
|
_publish_snapshot()
|
|
return true
|
|
|
|
|
|
## Queue one deferred MCP response onto the same aggregate refresh worker the
|
|
## Dock uses. The dispatcher bounds its pending table; this local cap also
|
|
## protects direct/future callers. No connection or handler is retained.
|
|
func request_mcp_status(request_id: String) -> bool:
|
|
if (
|
|
request_id.is_empty()
|
|
or not _accepting_work
|
|
or _client_health_blocked
|
|
or _refresh_state == RefreshState.SHUTTING_DOWN
|
|
or _mcp_status_waiters.has(request_id)
|
|
or _mcp_status_waiters.size() >= MAX_MCP_STATUS_WAITERS
|
|
):
|
|
return false
|
|
var target_generation := _refresh_generation + 1
|
|
_mcp_status_waiters[request_id] = target_generation
|
|
request_status_refresh(ClientConfigurator.client_ids(), true)
|
|
if (
|
|
_refresh_generation >= target_generation
|
|
or _refresh_pending
|
|
or _refresh_state == RefreshState.DEFERRED_FOR_FILESYSTEM
|
|
):
|
|
return true
|
|
_mcp_status_waiters.erase(request_id)
|
|
return false
|
|
|
|
|
|
func _run_status_refresh(
|
|
probes: Array[Dictionary],
|
|
server_url: String,
|
|
context: Dictionary,
|
|
generation: int,
|
|
) -> Dictionary:
|
|
var results: Dictionary = {}
|
|
var resolved_launch := ClientConfigurator.resolve_attach_launch(context)
|
|
for probe in probes:
|
|
var client_id := String(probe.get("id", ""))
|
|
if client_id.is_empty():
|
|
continue
|
|
var details := ClientConfigurator.check_status_details_for_url_with_cli_path(
|
|
client_id,
|
|
server_url,
|
|
String(probe.get("cli_path", "")),
|
|
context,
|
|
resolved_launch,
|
|
)
|
|
results[client_id] = {
|
|
"status": details.get("status", Client.Status.NOT_CONFIGURED),
|
|
"installed": bool(probe.get("installed", false)),
|
|
"error_msg": details.get("error_msg", ""),
|
|
}
|
|
return {"results": results, "generation": generation}
|
|
|
|
|
|
func _poll_refresh() -> void:
|
|
if _refresh_thread == null or _refresh_thread.is_alive():
|
|
return
|
|
var payload: Variant = _refresh_thread.wait_to_finish()
|
|
_refresh_thread = null
|
|
if not (payload is Dictionary):
|
|
_finish_refresh({}, _refresh_generation, "Client status worker returned an invalid response.")
|
|
return
|
|
var data := payload as Dictionary
|
|
if int(data.get("generation", -1)) != _refresh_generation:
|
|
return
|
|
var results := (data.get("results", {}) as Dictionary).duplicate(true)
|
|
var timed_out := _refresh_state == RefreshState.RUNNING_TIMED_OUT
|
|
_finish_refresh(
|
|
{} if timed_out else results,
|
|
int(data.get("generation", _refresh_generation)),
|
|
"Client status probe timed out." if timed_out else "",
|
|
)
|
|
|
|
|
|
func _finish_refresh(
|
|
results: Dictionary, completed_generation: int = -1, mcp_error: String = ""
|
|
) -> void:
|
|
_refresh_completed_msec = Time.get_ticks_msec()
|
|
if _refresh_state != RefreshState.SHUTTING_DOWN:
|
|
_refresh_state = RefreshState.IDLE
|
|
if not _client_health_blocked and _refresh_state != RefreshState.SHUTTING_DOWN:
|
|
status_refresh_completed.emit(results.duplicate(true))
|
|
_complete_mcp_status_waiters(results, completed_generation, mcp_error)
|
|
_publish_snapshot()
|
|
if _refresh_pending:
|
|
var force := _refresh_pending_force
|
|
_refresh_pending = false
|
|
_refresh_pending_force = false
|
|
request_status_refresh(_client_ids, force)
|
|
|
|
|
|
func _complete_mcp_status_waiters(
|
|
results: Dictionary, completed_generation: int, error: String
|
|
) -> void:
|
|
if completed_generation < 0 or _mcp_status_waiters.is_empty():
|
|
return
|
|
var completed: Array[String] = []
|
|
for request_id in _mcp_status_waiters:
|
|
if int(_mcp_status_waiters[request_id]) <= completed_generation:
|
|
completed.append(String(request_id))
|
|
for request_id in completed:
|
|
_mcp_status_waiters.erase(request_id)
|
|
if completed.is_empty():
|
|
return
|
|
var payload := (
|
|
McpErrorCodes.make(McpErrorCodes.INTERNAL_ERROR, error)
|
|
if not error.is_empty()
|
|
else ClientConfigurator.client_status_response(results)
|
|
)
|
|
mcp_status_completed.emit(completed, payload.duplicate(true))
|
|
|
|
func _refresh_timed_out() -> bool:
|
|
return (
|
|
RefreshState.has_worker_alive(_refresh_state)
|
|
and _refresh_started_msec > 0
|
|
and Time.get_ticks_msec() - _refresh_started_msec >= STATUS_TIMEOUT_MSEC
|
|
)
|
|
|
|
|
|
func _check_refresh_timeout() -> void:
|
|
if _refresh_state == RefreshState.RUNNING and _refresh_timed_out():
|
|
_refresh_state = RefreshState.RUNNING_TIMED_OUT
|
|
_publish_snapshot()
|
|
|
|
|
|
func _retry_deferred_refresh() -> void:
|
|
if _refresh_state != RefreshState.DEFERRED_FOR_FILESYSTEM:
|
|
return
|
|
if not _accepting_work or _client_health_blocked or _filesystem_busy():
|
|
return
|
|
var force := _refresh_pending_force
|
|
_refresh_state = RefreshState.IDLE
|
|
_refresh_pending_force = false
|
|
request_status_refresh(_client_ids, force)
|
|
|
|
|
|
func _filesystem_busy() -> bool:
|
|
var filesystem := EditorInterface.get_resource_filesystem()
|
|
return filesystem != null and filesystem.is_scanning()
|
|
|
|
|
|
func _warm_worker_bytecode() -> void:
|
|
var ids := ClientConfigurator.client_ids()
|
|
if ids.is_empty():
|
|
return
|
|
var client := ClientRegistry.get_by_id(String(ids[0]))
|
|
if client != null:
|
|
JsonStrategy.verify_entry(client, {}, "")
|
|
DshStrategy.command_launch_error(client, {})
|
|
TomlStrategy.format_body(PackedStringArray(), "")
|
|
CliStrategy.format_args(PackedStringArray(), "", "")
|
|
ClientConfigurator.warm_env_snapshot()
|
|
|
|
|
|
func _set_action_cancelled(client_id: String, cancelled: bool) -> void:
|
|
_action_cancel_mutex.lock()
|
|
if cancelled:
|
|
_action_cancelled[client_id] = true
|
|
else:
|
|
_action_cancelled.erase(client_id)
|
|
_action_cancel_mutex.unlock()
|
|
|
|
|
|
func _is_action_cancelled(client_id: String) -> bool:
|
|
_action_cancel_mutex.lock()
|
|
var cancelled := bool(_action_cancelled.get(client_id, false))
|
|
_action_cancel_mutex.unlock()
|
|
return cancelled
|
|
|
|
|
|
func _set_post_update_cancelled(cancelled: bool) -> void:
|
|
_post_update_cancel_mutex.lock()
|
|
_post_update_cancelled = cancelled
|
|
_post_update_cancel_mutex.unlock()
|
|
|
|
|
|
func _is_post_update_cancelled() -> bool:
|
|
_post_update_cancel_mutex.lock()
|
|
var cancelled := _post_update_cancelled
|
|
_post_update_cancel_mutex.unlock()
|
|
return cancelled
|
|
|
|
|
|
func _set_action_phase(client_id: String, phase: String) -> void:
|
|
_action_phase_mutex.lock()
|
|
_action_phases[client_id] = phase
|
|
_action_phase_mutex.unlock()
|
|
|
|
|
|
func _read_action_phase(client_id: String) -> String:
|
|
_action_phase_mutex.lock()
|
|
var phase := String(_action_phases.get(client_id, ""))
|
|
_action_phase_mutex.unlock()
|
|
return phase
|
|
|
|
|
|
func _read_action_phases() -> Dictionary:
|
|
_action_phase_mutex.lock()
|
|
var phases := _action_phases.duplicate(true)
|
|
_action_phase_mutex.unlock()
|
|
return phases
|
|
|
|
|
|
func _clear_action_phase(client_id: String) -> void:
|
|
_action_phase_mutex.lock()
|
|
_action_phases.erase(client_id)
|
|
_action_phase_mutex.unlock()
|