Cross-Server Agent Connection Management Implementation Plan
Cross-Server Agent Connection Management Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Provide cluster-wide Agent connection query and exact-fenced close from the admin UI.
Architecture: Register every physical Agent WebSocket as a cluster lease, keep the process-local session manager as the only execution authority, and add an authenticated mTLS relay control RPC so one Server node can ask another to close its local connection. The management API merges lease and local state and enforces owner/admin RBAC.
Tech Stack: Go, MySQL/SQLite storage repositories, etcd-compatible NodeRegistry, gRPC/mTLS relay, Vue 3, TypeScript, Element Plus.
Spec: docs/superpowers/specs/2026-09-09-cross-server-agent-connection-management-design.md
Global Constraints
- Work directly in
/opt/app/workspace/TunnelMesh; do not use a worktree. - Preserve all existing uncommitted changes; do not reset, revert, commit, push, or merge.
- Use TDD: write each failing test before its implementation.
- No database schema change is allowed in this feature.
- Management response envelope remains
{ code, msg, data }. - All authorization is server-side and owner-checked.
- Logs and audits must not contain tokens, raw credentials, target addresses, or stream payloads.
Task 1: Agent Connection Lease Lifecycle ✅
Files:
- Create:
internal/server/agent_connection_registry.go - Create:
internal/server/agent_connection_registry_test.go - Modify:
internal/server/runtime.go - Modify:
internal/server/session_manager.go - Modify:
internal/server/ws_agent.go - Test:
internal/server/runtime_test.go
Interfaces:
- Produces:
type AgentConnectionLeaseController struct{}
func NewAgentConnectionLeaseController(
reg registry.NodeRegistry,
manager *AgentSessionManager,
localRelay *AgentRelayTransport,
serverNodeID string,
ttl time.Duration,
) *AgentConnectionLeaseController
func (c *AgentConnectionLeaseController) Register(
ctx context.Context, session *AgentSession,
) (registry.NodeOwner, error)
func (c *AgentConnectionLeaseController) Heartbeat(
ctx context.Context, owner registry.NodeOwner, session *AgentSession,
) error
func (c *AgentConnectionLeaseController) Release(
ctx context.Context, owner registry.NodeOwner,
) error
AgentSessionConfiggains:
HeartbeatCallback func(context.Context, *AgentSession) error
- Step 1: Write failing lifecycle tests
Create tests for:
func TestAgentConnectionLeaseControllerRegistersRenewsAndReleases(t *testing.T)
func TestAgentConnectionLeaseControllerHeartbeatUpdatesActiveStreams(t *testing.T)
func TestAgentConnectionLeaseControllerReleaseIsFenced(t *testing.T)
func TestRuntimeRejectsAgentConnectionWhenLeaseRegistrationFails(t *testing.T)
Assertions must verify:
owner := controller.Register(ctx, session)
owner.AgentID == session.AgentID
owner.ConnectionID == session.ConnectionID
owner.ConnectionEpoch == session.ConnectionEpoch
owner.ServerNodeID == serverNodeID
controller.Heartbeat(ctx, owner, session)
activeLeases, _ := db.Leases().ListActiveByAgent(ctx, session.AgentID)
activeLeases[0].ActiveStreams == localRelay.ActiveStreams(session.AgentID, session.ConnectionID)
controller.Release(ctx, owner)
remaining, _ := db.Leases().ListActiveByAgent(ctx, session.AgentID)
len(remaining) == 0
- Step 2: Run failing tests
Run:
go test ./internal/server -run 'TestAgentConnectionLeaseController|TestRuntimeRejectsAgentConnectionWhenLeaseRegistrationFails' -count=1
Expected result: compile failure because AgentConnectionLeaseController does not exist.
- Step 3: Implement the lease controller
Implementation requirements:
Registercallsregistry.RegisterwithNodeIDandServerNodeIDboth set to the local Server node ID.- TTL defaults to 90 seconds.
Heartbeatuses a one-second bounded context, callsKeepAlive, thenUpdateConnectionStats.ReleasecallsRevokewith the exact owner epoch.AgentSessionManager.Registerdoes not own registry persistence.- Runtime registers the lease after local session registration succeeds and rejects the WebSocket if cluster lease registration fails.
serveRegisteredAgentSessionWithMetricsinvokesHeartbeatCallbackforPINGandPONGafter refreshing metadata.-
Runtime defer releases the exact owner after the Agent read loop exits.
- Step 4: Verify task
Run:
go test ./internal/server -run 'TestAgentConnectionLeaseController|TestRuntimeRejectsAgentConnectionWhenLeaseRegistrationFails' -count=1
go test ./internal/server -count=1
Expected result: all selected and package tests pass.
Task 2: Exact-Fenced Local Connection Close ✅
Files:
- Modify:
internal/server/session_manager.go - Test:
internal/server/session_test.go
Interfaces:
- Produces:
func (m *AgentSessionManager) CloseConnection(
agentID, connectionID string, connectionEpoch int64,
) error
- Step 1: Write failing tests
Add:
func TestAgentSessionManagerCloseConnectionSendsGoAway(t *testing.T)
func TestAgentSessionManagerCloseConnectionRejectsStaleEpoch(t *testing.T)
func TestAgentSessionManagerCloseConnectionDoesNotAffectSiblingConnection(t *testing.T)
Required assertions:
err := manager.CloseConnection("agent-a", "conn-a", 7)
errors.Is(err, ErrEpoch) == false
transport.closed == true
lastFrame.Type == protocol.FrameGoAway
err = manager.CloseConnection("agent-a", "conn-a", 6)
errors.Is(err, ErrEpoch) == true
replacementTransport.closed == false
- Step 2: Run failing tests
Run:
go test ./internal/server -run 'TestAgentSessionManagerCloseConnection' -count=1
Expected result: compile failure because CloseConnection does not exist.
- Step 3: Implement exact close
Implementation requirements:
- Resolve the session by Agent ID and connection ID.
- Reject a mismatched
connectionEpochwithErrEpoch. - Mark the session closing, drain queued frames, send
GOAWAY, close the transport, and return the transport error. -
Do not remove the map entry directly; let the existing read-loop cleanup call
RemoveSessionso replacement fencing remains intact. - Step 4: Verify task
Run:
go test ./internal/server -run 'TestAgentSessionManagerCloseConnection' -count=1
go test ./internal/server -count=1
Expected result: all tests pass.
Task 3: Authenticated Relay Control RPC
Files:
- Modify:
internal/relay/transport.go - Modify:
internal/relay/transport_test.go - Modify:
internal/relay/server_node_auth_test.go - Modify:
internal/server/runtime.go - Test:
internal/server/agent_relay_transport_test.go
Interfaces:
- Produces in
internal/relay:
type CloseAgentConnectionRequest struct {
AgentID string
ConnectionID string
ConnectionEpoch int64
RequestedByNodeID string
}
type CloseAgentConnectionFunc func(context.Context, CloseAgentConnectionRequest) error
func (n *GRPCNodeTransport) CloseAgentConnection(
ctx context.Context, req CloseAgentConnectionRequest,
) error
- Server registration accepts a
CloseAgentConnectionFunc. -
The unary RPC path is
/tunnelmesh.relay.v1.Relay/CloseAgentConnection. - Step 1: Write failing relay tests
Add tests for:
func TestRelayCloseAgentConnectionRequiresServerNodeAuth(t *testing.T)
func TestRelayCloseAgentConnectionUsesExactEpoch(t *testing.T)
func TestGRPCNodeTransportCloseAgentConnectionPropagatesFailure(t *testing.T)
Required assertions:
err := client.CloseAgentConnection(ctx, relay.CloseAgentConnectionRequest{
AgentID: "agent-a", ConnectionID: "conn-a", ConnectionEpoch: 9,
})
status.Code(err) == codes.PermissionDenied
closeCalls[0].ConnectionEpoch == 9
status.Code(staleErr) == codes.FailedPrecondition
- Step 2: Run failing tests
Run:
go test ./internal/relay -run 'TestRelayCloseAgentConnection|TestGRPCNodeTransportCloseAgentConnection' -count=1
Expected result: compile failure because the control request and RPC do not exist.
- Step 3: Implement the unary RPC
Implementation requirements:
- Add a unary service descriptor alongside
OpenStream. - Add a server-node unary authentication interceptor using the same identity and token validation as the stream interceptor.
- Encode the request as a protobuf
Structusing snake_case metadata keys. - Apply a five-second server-side context timeout.
- Runtime installs a handler that calls
AgentSessionManager.CloseConnection. - Map
ErrEpochtocodes.FailedPreconditionand missing sessions tocodes.NotFound. -
Keep
GRPCNodeTransport.Close()unchanged. - Step 4: Verify task
Run:
go test ./internal/relay -count=1
go test ./internal/server -run 'AgentRelay|CloseConnection' -count=1
Expected result: all relay and related server tests pass.
Task 4: Cluster-Wide Query and Close API
Files:
- Create:
internal/server/agent_connection_api.go - Create:
internal/server/agent_connection_api_test.go - Modify:
internal/server/api.go - Modify:
docs/api/openapi.yaml
Interfaces:
- Produces:
type AgentConnectionClusterView struct {
AgentID string `json:"agentId"`
InstanceID string `json:"instanceId"`
ConnectionID string `json:"connectionId"`
ConnectionEpoch int64 `json:"connectionEpoch"`
ServerNodeID string `json:"serverNodeId"`
ServerNodeEpoch int64 `json:"serverNodeEpoch"`
ServerNodeAddress string `json:"serverNodeAddress"`
Healthy bool `json:"healthy"`
ActiveStreams int `json:"activeStreams"`
HealthScore int64 `json:"healthScore"`
LastHeartbeatAt time.Time `json:"lastHeartbeatAt"`
LeaseExpiresAt time.Time `json:"leaseExpiresAt"`
Local bool `json:"local"`
}
type AgentConnectionCloseService interface {
Close(ctx context.Context, agentID, connectionID string, epoch int64) error
}
- Routes:
GET /api/v1/agents/{agentId}/connectionsDELETE /api/v1/agents/{agentId}/connections/{connectionId}?connectionEpoch={epoch}
- Step 1: Write failing API tests
Add:
func TestAgentConnectionsListMergesLocalAndRemoteLeases(t *testing.T)
func TestAgentConnectionsListEnforcesOwnership(t *testing.T)
func TestAgentConnectionsCloseLocalExactConnection(t *testing.T)
func TestAgentConnectionsCloseRemoteThroughControlService(t *testing.T)
func TestAgentConnectionsCloseRejectsStaleEpoch(t *testing.T)
func TestAgentConnectionsCloseIsIdempotent(t *testing.T)
func TestAgentConnectionsCloseWritesAudit(t *testing.T)
Required assertions:
response.Code == http.StatusOK
len(data.Connections) == 2
data.Connections[0].Local == true
data.Connections[1].ServerNodeID == "server-b"
remoteService.closeRequests[0].ConnectionID == "conn-remote"
remoteService.closeRequests[0].ConnectionEpoch == 11
audit.Action == "agent.connection.closed_by_admin"
- Step 2: Run failing tests
Run:
go test ./internal/server -run 'TestAgentConnections' -count=1
Expected result: compile or 404 failure because the routes do not exist.
- Step 3: Implement the API
Implementation requirements:
- Resolve and authorize the Agent before reading leases.
- Query
registry.ListAgentConnections. - Overlay local manager state when
ServerNodeIDequals the local node ID. - For remote views, use lease
ActiveStreams,HealthScore, and expiry; markHealthyas true only when the lease is active. - Require a positive
connectionEpoch. - Close locally when the owner is this node.
- For remote owners, dial the lease address using the existing relay client configuration and invoke
CloseAgentConnection. - Return
503when the remote node is unreachable; do not delete the lease. - Treat a lease that disappeared before close as successful idempotency.
- Write an audit record for both successful and failed close attempts.
-
Update OpenAPI schemas, parameters, responses, and authorization descriptions.
- Step 4: Verify task
Run:
go test ./internal/server -run 'TestAgentConnections' -count=1
go test ./internal/server -count=1
Expected result: all API and server tests pass.
Task 5: Admin UI
Files:
- Modify:
web/src/api/client.ts - Modify:
web/src/views/AgentDetail.vue - Modify:
web/src/i18n/messages/zh-CN.ts - Modify:
web/src/i18n/messages/en-US.ts - Test:
web/src/tests/agent-detail.spec.ts
Interfaces:
- Produces:
export type ClusterAgentConnection = {
agentId: string
instanceId: string
connectionId: string
connectionEpoch: number
serverNodeId: string
serverNodeEpoch: number
serverNodeAddress?: string
healthy: boolean
activeStreams: number
healthScore: number
lastHeartbeatAt?: string
leaseExpiresAt: string
local: boolean
}
export function listAgentConnections(agentId: string): Promise<ClusterAgentConnection[]>
export function closeAgentConnection(
agentId: string,
connectionId: string,
connectionEpoch: number,
): Promise<{ closed: boolean }>
- Step 1: Write failing frontend tests
Add tests that verify:
await wrapper.find('[data-test="refresh-connections"]').trigger('click')
expect(listAgentConnections).toHaveBeenCalledWith('agent-1')
await wrapper.find('[data-test="close-connection-conn-1"]').trigger('click')
await wrapper.find('[data-test="confirm-close-connection"]').trigger('click')
expect(closeAgentConnection).toHaveBeenCalledWith('agent-1', 'conn-1', 7)
Also test the remote-node column and the error message shown when close returns 503.
- Step 2: Run failing tests
Run:
cd web
npm test -- --run src/tests/agent-detail.spec.ts
Expected result: failure because the UI actions and API helpers do not exist.
- Step 3: Implement the UI
Implementation requirements:
- Add a refresh button above the connection table.
- Add columns for Server node and lease expiry.
- Add a danger “Close” action for each connection.
- Confirm dialog shows Agent ID, connection ID, epoch, server node, and active stream count.
- Disable the confirm button while closing.
- Reload connections after success.
-
Show a localized warning for remote-node unavailability.
- Step 4: Verify task
Run:
cd web
npm test -- --run
npm run build
Expected result: frontend tests and production build pass.
Task 6: Documentation, Embed Sync, and Full Verification
Files:
- Modify:
docs/user-guide/server-admin.md - Modify:
docs/operations/connection-pool.md - Modify:
docs/operations/troubleshooting.md - Modify:
docs/api/openapi.yaml -
Regenerate:
internal/server/web_dist - Step 1: Update documentation
Document:
- Cross-node prerequisites: unique
node.id, enabled relay mTLS, valid server-node token, and reachable relay address. - Query and close API examples.
- The fact that closing one physical connection does not disable the Agent or prevent reconnect.
- Why an unreachable owner returns
503and the lease is retained. -
Rollout note: all participating Server nodes must run the new version for complete cluster visibility.
- Step 2: Sync embedded frontend
Run:
rm -rf internal/server/web_dist
cp -R web/dist internal/server/web_dist
Before running deletion, confirm that web/dist exists and internal/server/web_dist contains only generated assets.
- Step 3: Run full verification
Run:
go test ./... -count=1
go test -race ./...
go vet ./...
git diff --check
cd web
npm test -- --run
npm run build
Expected result: every command exits successfully.
Final Review Checklist
- Registry registration, renewal, statistics, and release are wired into the real Agent WebSocket lifecycle.
- Query merges local and remote state without leaking another owner’s Agent.
- Close is exact-fenced by connection ID and connection epoch.
- Remote close uses authenticated mTLS and does not expose a public unauthenticated control endpoint.
- Unreachable owner returns
503and preserves the lease. - UI supports query, refresh, and close in Chinese and English.
- OpenAPI, user guide, operations guide, troubleshooting guide, and embedded assets are updated.
- All required Go and frontend validation commands pass.