ConnectionStateMachine in openclaw-windows-node: How It Handles Operator and Node Role Transitions

The ConnectionStateMachine in openclaw-windows-node is a dual-role finite state machine that coordinates independent operator and node sub-FSMs by validating and applying ConnectionTrigger events through TryTransition, then rebuilding an immutable GatewayConnectionSnapshot that exposes the combined overall state to the rest of the application.

The ConnectionStateMachine class sits at the heart of the connection logic in the openclaw/openclaw-windows-node repository, isolating the lifecycle of the WebSocket-based operator client from the Windows-node client. Implemented in src/OpenClaw.Connection/ConnectionStateMachine.cs, this internal state machine translates external connection events into structured role transitions and exposes a unified snapshot used by the UI and diagnostics. Understanding how it partitions triggers between the operator and node roles is essential for debugging connection issues or extending protocol support in the openclaw-windows-node codebase.

What Is the ConnectionStateMachine?

ConnectionStateMachine is an internal class in the OpenClaw.Connection project that manages two independent sub-finite-state-machines: one for the operator role (the WebSocket client that communicates with the gateway) and one for the node role (the Windows-node client running on the local machine). Rather than mixing states in a single flat enum, the machine maintains separate state tracks for each role and derives a high-level OverallState only after both tracks are evaluated.

Each role progresses through values defined in the RoleConnectionState enum: Idle, Connecting, Connected, PairingRequired, Error, RateLimited, Disabled, and PairingRejected. External events are expressed as ConnectionTrigger values, which are processed by the TryTransition(trigger, detail?) method. This method first consults CanTransition(trigger) to enforce valid moves, then invokes ApplyTransition and rebuilds the snapshot via RebuildSnapshot. The resulting view is an immutable GatewayConnectionSnapshot that includes OverallState, OperatorState, NodeState, device IDs, and any error details.

How Role Transitions Work

Role transitions in ConnectionStateMachine are strictly separated: operator triggers never affect the node sub-FSM and vice versa. After each successful transition, the machine calls GatewayConnectionSnapshot.DeriveOverall to compute the unified state consumed by tray icons, status windows, and diagnostics.

Operator Role Transitions

The operator sub-FSM begins in Idle. When GatewayConnectionManager requests a connection, it feeds the ConnectRequested trigger into TryTransition, moving the operator to Connecting.

While in Connecting, intermediate triggers such as ConnectRequestSent, ChallengeReceived, and WebSocketConnected keep the state in Connecting. Successful authentication emits HandshakeSucceeded, which advances the operator to Connected. If the gateway signals that pairing is required, the PairingPending trigger shifts the state to PairingRequired. After user approval, PairingApproved returns the operator to Connecting so the client can reconnect. Error paths—authentication failures, rate limiting, or transport errors—push the operator into Error. A subsequent ReconnectScheduled trigger restarts the flow back to Connecting.

Node Role Transitions

The node sub-FSM is activated only when node mode is enabled via SetNodeEnabled(true). It starts in Idle or Disabled. When the local node client reports success, the NodeConnected trigger moves the node to Connected.

If the gateway asks the node to pair, NodePairingRequired transitions the state to PairingRequired. From there, NodePaired resolves to Connected, whereas a NodePairingRejected trigger lands in PairingRejected. Transport problems surface through NodeError or NodeRateLimited, both of which force the node into Error.

Overall State Derivation

The overall state is not stored directly; it is derived from the current OperatorState, NodeState, and the node-enabled flag. As implemented in openclaw-windows-node, if both the operator and node are Connected, the derived OverallState becomes Ready. If the operator is in Error, the overall state is Error regardless of the node track. The UI-facing overall values also include states such as Degraded and PairingRequired. This logic is documented in docs/CONNECTION_ARCHITECTURE.md and executed inside RebuildSnapshot.

Thread Safety and Integration with GatewayConnectionManager

ConnectionStateMachine itself is not thread-safe; callers must serialize access. In practice, GatewayConnectionManager—defined in src/OpenClaw.Connection/GatewayConnectionManager.cs—owns the single _stateMachine instance and enforces mutual exclusion with a _transitionSemaphore of type SemaphoreSlim.

All low-level WebSocket and node-connector events are translated into ConnectionTrigger values and passed to _stateMachine.TryTransition. Specifically:

  • HandleOperatorStatusChangedAsync maps operator connection events to triggers like WebSocketConnected or WebSocketError.
  • OnNodeStatusChangedAsync maps node events to triggers like NodeConnected, NodeDisconnected, or NodeError.

After each transition succeeds, EmitStateChanged fires the StateChanged event and passes the new GatewayConnectionSnapshot so that UI layers can render the latest status without polling.

Code Examples

Direct State Machine Usage

The unit tests in tests/OpenClaw.Connection.Tests/ConnectionStateMachineTests.cs demonstrate the expected sequencing:

var sm = new ConnectionStateMachine();

// Operator connect flow
bool ok = sm.TryTransition(ConnectionTrigger.ConnectRequested);
ok &= sm.TryTransition(ConnectionTrigger.WebSocketConnected);
ok &= sm.TryTransition(ConnectionTrigger.HandshakeSucceeded);

// Node start (assuming node mode is enabled)
sm.SetNodeEnabled(true);
ok &= sm.TryTransition(ConnectionTrigger.NodeConnected);

// Snapshot now reflects a fully-connected system
var snap = sm.Current;   // OverallState = Ready

This pattern shows how ConnectionStateMachine keeps the two roles isolated while exposing a single unified snapshot through sm.Current.

Mapping Operator Events to State Triggers

Inside GatewayConnectionManager.cs (around lines 665–709), operator status changes are converted into state-machine triggers:

// Inside GatewayConnectionManager.HandleOperatorStatusChangedAsync
if (status == ConnectionStatus.Connected)
{
    _stateMachine.TryTransition(ConnectionTrigger.WebSocketConnected);
}
else if (status == ConnectionStatus.Error)
{
    _stateMachine.TryTransition(ConnectionTrigger.WebSocketError, "Transport error");
}
EmitStateChanged(prev);

This code illustrates the thin translation layer between the WebSocket client and the ConnectionStateMachine.

Mapping Node Events to State Triggers

Node-connector events follow the same pattern in GatewayConnectionManager.cs (around lines 880–904):

// Inside OnNodeStatusChangedAsync
switch (status)
{
    case ConnectionStatus.Connected:
        _stateMachine.TryTransition(ConnectionTrigger.NodeConnected);
        break;
    case ConnectionStatus.Connecting:
        _stateMachine.StartNodeConnecting();
        break;
    case ConnectionStatus.Disconnected:
        _stateMachine.TryTransition(ConnectionTrigger.NodeDisconnected);
        break;
    case ConnectionStatus.Error:
        _stateMachine.TryTransition(ConnectionTrigger.NodeError, "Node transport error");
        break;
}
EmitStateChanged(prev);

Here, the manager handles node-specific signals such as NodeConnected and NodeError, ensuring the node sub-FSM advances independently of the operator track.

Summary

  • Dual sub-FSMs: ConnectionStateMachine maintains separate state tracks for the operator and node roles, ensuring triggers never cross between them.
  • Immutable snapshots: Each transition rebuilds a GatewayConnectionSnapshot that derives OverallState from the combined role states.
  • Centralized transition logic: The TryTransition pipeline relies on CanTransition, ApplyTransition, and RebuildSnapshot to enforce valid moves.
  • External serialization required: The class is not thread-safe; GatewayConnectionManager guards it with a SemaphoreSlim.
  • Clear file boundaries: Core logic lives in src/OpenClaw.Connection/ConnectionStateMachine.cs, while orchestration and event mapping reside in src/OpenClaw.Connection/GatewayConnectionManager.cs.

Frequently Asked Questions

What states can each role have in the ConnectionStateMachine?

Both the operator and node sub-FSMs use the RoleConnectionState enum, which includes Idle, Connecting, Connected, PairingRequired, Error, RateLimited, Disabled, and PairingRejected. The set of valid triggers differs per role, but the underlying state representation is shared.

Is the ConnectionStateMachine thread-safe?

No. The ConnectionStateMachine in openclaw-windows-node is explicitly not thread-safe. GatewayConnectionManager serializes all calls into _stateMachine.TryTransition by acquiring a _transitionSemaphore (SemaphoreSlim) before invoking any state-changing method.

How does the overall state derive from operator and node states?

After every transition, RebuildSnapshot invokes GatewayConnectionSnapshot.DeriveOverall. This method computes OverallState from OperatorState, NodeState, and whether node mode is enabled. For example, if both roles are Connected, the result is Ready; if the operator is in Error, the overall state becomes Error regardless of node status.

What happens when pairing is required?

If the gateway reports that pairing is required, the operator track receives the PairingPending trigger and moves to PairingRequired. Once the user approves, PairingApproved returns the operator to Connecting to complete reconnection. On the node side, NodePairingRequired moves the node to PairingRequired; a subsequent NodePaired trigger resolves to Connected, while NodePairingRejected ends in PairingRejected.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →