Files
tekton/addons/godot_ai/utils/mcp_server_state.gd
T

190 lines
7.6 KiB
GDScript

@tool
class_name McpServerState
extends RefCounted
## State machine for the plugin's server-spawn / adopt / version-verify
## lifecycle. Single source of truth — supersedes the boolean-flag thicket
## (`_server_started_this_session`, `_awaiting_server_version`,
## `_server_version_deadline_ms`, `_connection_blocked`,
## `_can_recover_incompatible`, `_refresh_retried`,
## `_adoption_watch_deadline_ms`) and the older terminal-only
## McpSpawnState string union.
##
## The integer values matter — they're what `get_server_status()`
## surfaces, what the dock pattern-matches on, and what the test suites
## assert against. Reordering the enum is a breaking change.
##
## The transitions are documented in `can_transition()`. The lifecycle
## manager calls `set_state()` which:
## 1. Validates the transition (logs a warning + no-ops on illegal).
## 2. Preserves first-writer-wins among terminal diagnoses so a late
## CRASHED from the watch loop can't clobber an earlier
## PORT_EXCLUDED from the proactive Windows reservation check.
## Fresh plugin instance, `_start_server` has not run yet. Default state.
const UNINITIALIZED := 0
## Process spawned via OS.create_process; watch loop is observing the
## SPAWN_GRACE_MS window. Transitions directly to READY (handshake_ack
## verifies a compatible version), CRASHED (process died early), or
## INCOMPATIBLE (handshake reported a mismatch).
const SPAWNING := 1
## (slot 2 reserved — keep wire-compat for clients pattern-matching
## numeric `editor_state.state` values; do not reuse.)
## Server is healthy and version-verified. Happy path. Includes both
## "spawned fresh" and "adopted compatible existing server" flavors —
## adoption flavor is recorded separately via `McpAdoptionLabel`.
const READY := 3
## Live server on the HTTP port returned a version that doesn't match
## what this plugin expects, OR returned no `handshake_ack` inside the
## timeout. Connection is blocked; recovery requires a kill+respawn
## click via `recover_incompatible_server`.
const INCOMPATIBLE := 4
## Spawned process exited inside the SPAWN_GRACE_MS window. Python
## traceback went to Godot's output log. Terminal — reload the plugin
## or restart the editor to retry.
const CRASHED := 5
## No server command resolved: no `.venv` Python, no `uvx` on PATH, no
## system `godot-ai`. Terminal — install guidance shown in dock.
const NO_COMMAND := 6
## Windows reserved the HTTP port via Hyper-V / WSL2 / Docker exclusion
## range. Caught proactively before bind. Terminal — port picker shown.
const PORT_EXCLUDED := 7
## HTTP port held by a process we didn't spawn (no matching managed
## record). Plugin armed an adoption-confirmation watcher; if the foreign
## occupant turns out to be a compatible godot-ai server,
## `handle_server_version_verified` transitions to READY. If the
## adoption deadline expires without a connection, the watcher self-
## disarms but the state stays at FOREIGN_PORT — the dock keeps showing
## "port held by another process" until the user reloads. The version-
## check seam (separate from the adoption deadline) is what fires
## INCOMPATIBLE on a positive-but-mismatched handshake.
const FOREIGN_PORT := 8
## Static re-entrancy guard fired (`_server_started_this_session` was
## already true). The plugin is being re-enabled within the same editor
## session; the previous instance still owns the spawn. Terminal — does
## NOT block READY paths, just records that this enable cycle no-op'd.
const GUARDED := 9
## stop_server / prepare_for_update_reload in progress. Transitional —
## next state is STOPPED.
const STOPPING := 10
## stop_server completed; `_server_pid` reset to -1, port may or may
## not be free. From here a fresh `start_server` call moves back through
## SPAWNING / READY.
const STOPPED := 11
const _NAMES := {
UNINITIALIZED: "uninitialized",
SPAWNING: "spawning",
READY: "ready",
INCOMPATIBLE: "incompatible",
CRASHED: "crashed",
NO_COMMAND: "no_command",
PORT_EXCLUDED: "port_excluded",
FOREIGN_PORT: "foreign_port",
GUARDED: "guarded",
STOPPING: "stopping",
STOPPED: "stopped",
}
## Human-readable label. Used in startup-trace logs and transition
## warnings. Falls back to `unknown(<int>)` for unrecognised values so
## a future enum addition won't crash the formatter.
static func name_of(state: int) -> String:
return _NAMES.get(state, "unknown(%d)" % state)
## True for any state the dock should render as a non-OK diagnostic
## panel. Used as the "should we hide the spawn-failure panel?" gate.
static func is_terminal_diagnosis(state: int) -> bool:
return (
state == CRASHED
or state == NO_COMMAND
or state == PORT_EXCLUDED
or state == INCOMPATIBLE
or state == FOREIGN_PORT
)
## True only for READY. Other "ok-ish" states (SPAWNING) are still in
## flight; READY is the only state where the plugin can treat the server
## as fully healthy.
static func is_healthy(state: int) -> bool:
return state == READY
## True when the dock should consider the server unsuitable for client
## health checks (incompatible tool surface). Currently just INCOMPATIBLE
## — FOREIGN_PORT is transitional and may resolve to READY if the
## foreign occupant turns out to speak our handshake.
static func blocks_client_health(state: int) -> bool:
return state == INCOMPATIBLE
## Transition validation table. Returns true when `from -> to` is a
## legal transition the lifecycle manager should accept. Illegal
## transitions are silently no-op'd at the call site (with a
## `push_warning` log) — this preserves the first-writer-wins contract
## that prevents a late CRASHED from the watch loop overwriting an
## earlier PORT_EXCLUDED diagnosis.
static func can_transition(from: int, to: int) -> bool:
if from == to:
return true
## Stop is always legal — teardown / install reload short-circuits
## any in-flight state.
if to == STOPPING:
return true
if to == STOPPED and from == STOPPING:
return true
## STOPPED can also be reached directly when `_server_pid <= 0` and
## stop_server early-returns; treat it as legal from any state to
## keep the teardown path forgiving.
if to == STOPPED:
return true
## STOPPED -> any (re-arm via restart paths).
if from == STOPPED:
return true
## GUARDED is sticky for the rest of this enable cycle; only stop is
## legal out of it. Already covered by the stop checks above.
if from == GUARDED:
return false
## Terminal diagnoses freeze further forward transitions. Recovery
## goes through STOPPING (covered above), so any other target is
## rejected — this is the first-writer-wins contract.
if (
from == CRASHED
or from == NO_COMMAND
or from == PORT_EXCLUDED
or from == INCOMPATIBLE
):
return false
## UNINITIALIZED is the boot state — any target except STOPPING is
## reachable directly (start_server's early branches set
## terminal states without going through SPAWNING).
if from == UNINITIALIZED:
return true
## In-flight forward transitions.
match from:
SPAWNING:
return (
to == READY
or to == CRASHED
or to == FOREIGN_PORT
or to == INCOMPATIBLE
)
FOREIGN_PORT:
return to == READY or to == INCOMPATIBLE
READY:
## Late incompatibility detection (e.g. version verifier
## re-arms after a foreign-port reconnect that turns out
## to be incompatible after all).
return to == INCOMPATIBLE or to == CRASHED
STOPPING:
## Recovery rollback: kill-then-respawn paths that fail to
## free the port re-latch INCOMPATIBLE (so the dock keeps
## the diagnostic UI) or fall back to UNINITIALIZED (clean
## baseline for a follow-up `_set_incompatible_server`).
## STOPPING -> STOPPED is handled by the early checks above.
return to == INCOMPATIBLE or to == UNINITIALIZED
return false