Files
llmgw/internal/control/agentsvc/convert.go
T
ilkerandClaude Opus 5 75a8e76706 Survive a control plane restart without orphaning live engines
Not persisting replica state left one hole: restarting the control plane made it
forget every replica while the engines kept running. They held GPUs the
inventory believed were free, so the next placement failed to allocate, with an
error naming the new process rather than the old one.

The fix belongs on the agent, not in a database. A row can say "running" about a
process that died; the fleet cannot.

- the supervisor writes the full shard spec, not just a pid and command line. A
  pid identifies a process; the spec is what lets it be described back to the
  control plane and matched to a replica.
- Adopt takes over an engine a previous agent left running. It is not this
  process's child, so it cannot be waited on: it is watched by polling and
  stopped by signalling its group directly. Its earlier output is gone and the
  log says so rather than presenting emptiness as silence.
- every Register reports what the agent is running, not only the first, because
  a reconnect may be a control plane restart and has to recover the same way.
- the reconciler rebuilds replicas from those reports and reclaims their GPUs.
  Anything whose model or profile left the catalog is stopped; nothing will
  route to it and it is holding cards.
- an engine the agent cannot identify is killed. A process holding GPUs that
  nothing can describe is worse than no process, because placement hands those
  GPUs out again.

Stop was also wrong for adopted shards: it checked for an exec.Cmd, which they
do not have, and reported them stopped without killing anything.

TestAgentReconnects had been passing for no reason. httptest's
CloseClientConnections does nothing to an h2c stream — prior-knowledge h2c
hijacks the connection and httptest stops tracking hijacked ones — so the agent
never reconnected, and the assertion on the connection map passed on a stale
entry. Tests now sever connections through their own listener and count opened
streams.

Plan §11 also records what multi-node still needs, since placement landing early
makes it look further along than it is: workers never become Ready because the
agent health-checks every shard while only rank 0 serves HTTP; RayCommand is
written and tested but never called; and startup is fire-and-forget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 06:15:04 +02:00

226 lines
7.2 KiB
Go

// Package agentsvc is the control plane's end of the agent stream (plan §5.6).
package agentsvc
import (
"fmt"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
)
// NodeFromRegister converts a registration into an inventory node.
func NodeFromRegister(r *llmgwv1.Register) inventory.Node {
n := inventory.Node{
ID: r.GetNodeId(),
Hostname: r.GetHostname(),
MgmtAddr: r.GetMgmtAddr(),
Labels: r.GetLabels(),
State: inventory.Ready,
AgentVersion: r.GetAgentVersion(),
EngineVersions: r.GetEngineVersions(),
}
for _, g := range r.GetGpus() {
n.GPUs = append(n.GPUs, discovery.GPU{
Index: int(g.GetIndex()),
UUID: g.GetUuid(),
Name: g.GetName(),
MemoryTotalMB: int(g.GetMemoryTotalMb()),
MemoryUsedMB: int(g.GetMemoryUsedMb()),
ComputeCapability: g.GetComputeCapability(),
NUMANode: int(g.GetNumaNode()),
PCIBusID: g.GetPciBusId(),
DriverVersion: g.GetDriverVersion(),
CUDAVersion: g.GetCudaVersion(),
NVLinkPeers: intSlice(g.GetNvlinkPeers()),
Health: discovery.GPUHealth{
ECCErrors: int(g.GetEccErrors()),
TemperatureC: int(g.GetTemperatureC()),
Problems: g.GetProblems(),
},
})
}
for _, f := range r.GetFabrics() {
n.Fabrics = append(n.Fabrics, inventory.FabricAttachment{
Fabric: f.GetFabric(),
HCAs: f.GetHcas(),
RateGbps: int(f.GetRateGbps()),
})
}
return n
}
// RegisterFromInventory builds the registration an agent sends.
func RegisterFromInventory(nodeID, hostname, mgmtAddr, agentVersion string,
labels map[string]string, inv discovery.Inventory, fabrics []inventory.FabricAttachment,
) *llmgwv1.Register {
r := &llmgwv1.Register{
NodeId: nodeID, Hostname: hostname, MgmtAddr: mgmtAddr,
Labels: labels, AgentVersion: agentVersion,
}
for _, g := range inv.GPUs {
r.Gpus = append(r.Gpus, &llmgwv1.GPU{
Index: int32(g.Index),
Uuid: g.UUID,
Name: g.Name,
MemoryTotalMb: int32(g.MemoryTotalMB),
MemoryUsedMb: int32(g.MemoryUsedMB),
ComputeCapability: g.ComputeCapability,
NumaNode: int32(g.NUMANode),
PciBusId: g.PCIBusID,
DriverVersion: g.DriverVersion,
CudaVersion: g.CUDAVersion,
NvlinkPeers: int32Slice(g.NVLinkPeers),
Problems: g.Health.Problems,
TemperatureC: int32(g.Health.TemperatureC),
EccErrors: int32(g.Health.ECCErrors),
})
}
for _, f := range fabrics {
r.Fabrics = append(r.Fabrics, &llmgwv1.FabricAttachment{
Fabric: f.Fabric, Hcas: f.HCAs, RateGbps: int32(f.RateGbps),
})
}
return r
}
// AdoptedFromRegister converts an agent's running-shard report into the
// reconciler's view.
func AdoptedFromRegister(nodeID string, shards []*llmgwv1.RunningShard) []reconciler.AdoptedShard {
out := make([]reconciler.AdoptedShard, 0, len(shards))
for _, s := range shards {
a := reconciler.AdoptedShard{
NodeID: nodeID,
ReplicaID: s.GetReplicaId(),
ShardID: s.GetShardId(),
Rank: int(s.GetRank()),
GPUIndices: intSlice(s.GetGpuIndices()),
HCAs: s.GetHcas(),
HTTPPort: int(s.GetHttpPort()),
State: StateFromWire(s.GetState()),
Model: s.GetModel(),
Profile: s.GetProfile(),
Pool: s.GetPool(),
Fabric: s.GetFabric(),
}
if a.Rank == 0 && s.GetLeaderAddr() != "" {
a.Endpoint = fmt.Sprintf("http://%s:%d", s.GetLeaderAddr(), s.GetHttpPort())
}
out = append(out, a)
}
return out
}
// StartShardFromCommand converts a reconciler command into a wire message.
func StartShardFromCommand(cmd reconciler.ShardCommand) *llmgwv1.StartShard {
p := cmd.Spec.Profile
return &llmgwv1.StartShard{
ReplicaId: cmd.ReplicaID,
ShardId: cmd.ShardID,
Rank: int32(cmd.Spec.Rank),
Nnodes: int32(cmd.Spec.NNodes),
LeaderAddr: cmd.Spec.LeaderAddr,
LeaderPort: int32(cmd.Spec.LeaderPort),
GpuIndices: int32Slice(cmd.Spec.GPUIndices),
Hcas: cmd.Spec.HCAs,
MgmtInterface: cmd.Spec.MgmtInterface,
HttpPort: int32(cmd.Spec.HTTPPort),
Profile: &llmgwv1.EngineProfile{
Name: p.Name, Engine: p.Engine, GpuClass: p.GPUClass,
Tp: int32(p.TP), Pp: int32(p.PP),
GpusPerNode: int32(p.GPUsPerNode), Nodes: int32(p.Nodes),
Args: p.Args, Env: p.Env,
ModelPath: p.ModelPath, ServedModelName: p.ServedModelName,
},
StartupTimeoutMs: cmd.StartupTimeout.Milliseconds(),
Pool: cmd.Pool,
Fabric: cmd.Fabric,
}
}
// ShardSpecFromStart converts a wire message into an engine shard spec.
func ShardSpecFromStart(m *llmgwv1.StartShard) (engines.ShardSpec, time.Duration) {
p := m.GetProfile()
spec := engines.ShardSpec{
ReplicaID: m.GetReplicaId(),
ShardID: m.GetShardId(),
Rank: int(m.GetRank()),
NNodes: int(m.GetNnodes()),
LeaderAddr: m.GetLeaderAddr(),
LeaderPort: int(m.GetLeaderPort()),
GPUIndices: intSlice(m.GetGpuIndices()),
HCAs: m.GetHcas(),
MgmtInterface: m.GetMgmtInterface(),
HTTPPort: int(m.GetHttpPort()),
Pool: m.GetPool(),
Fabric: m.GetFabric(),
Profile: engines.Profile{
Name: p.GetName(), Engine: p.GetEngine(), GPUClass: p.GetGpuClass(),
TP: int(p.GetTp()), PP: int(p.GetPp()),
GPUsPerNode: int(p.GetGpusPerNode()), Nodes: int(p.GetNodes()),
Args: p.GetArgs(), Env: p.GetEnv(),
ModelPath: p.GetModelPath(), ServedModelName: p.GetServedModelName(),
},
}
return spec, time.Duration(m.GetStartupTimeoutMs()) * time.Millisecond
}
// stateWire maps supervisor states onto the wire enum.
var stateWire = map[supervisor.State]llmgwv1.ShardStatus_State{
supervisor.Pending: llmgwv1.ShardStatus_STATE_PENDING,
supervisor.Starting: llmgwv1.ShardStatus_STATE_STARTING,
supervisor.Loading: llmgwv1.ShardStatus_STATE_LOADING,
supervisor.Ready: llmgwv1.ShardStatus_STATE_READY,
supervisor.Draining: llmgwv1.ShardStatus_STATE_DRAINING,
supervisor.Stopped: llmgwv1.ShardStatus_STATE_STOPPED,
supervisor.Failed: llmgwv1.ShardStatus_STATE_FAILED,
}
// StateToWire converts a supervisor state for transmission.
func StateToWire(s supervisor.State) llmgwv1.ShardStatus_State {
if w, ok := stateWire[s]; ok {
return w
}
return llmgwv1.ShardStatus_STATE_UNSPECIFIED
}
// StateFromWire converts a received state.
//
// An unrecognised state is treated as Failed rather than guessed at: a newer
// agent reporting something this control plane does not understand must not be
// mistaken for a healthy shard.
func StateFromWire(w llmgwv1.ShardStatus_State) supervisor.State {
for s, v := range stateWire {
if v == w {
return s
}
}
return supervisor.Failed
}
func intSlice(v []int32) []int {
if len(v) == 0 {
return nil
}
out := make([]int, len(v))
for i, x := range v {
out[i] = int(x)
}
return out
}
func int32Slice(v []int) []int32 {
if len(v) == 0 {
return nil
}
out := make([]int32, len(v))
for i, x := range v {
out[i] = int32(x)
}
return out
}