2026-10-14 22:35:53 -07:00
|
|
|
package agents
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"slices"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"syscall"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-10-18 15:32:07 -07:00
|
|
|
"g.fc.run/ai/byre/internal/api"
|
2026-10-23 11:22:16 -07:00
|
|
|
"golang.org/x/sys/unix"
|
2026-10-14 22:35:53 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
StuckAfter = 5 * time.Minute
|
|
|
|
|
healthInterval = 15 * time.Second
|
|
|
|
|
keptBadLines = 50
|
|
|
|
|
badLineBytes = 4096
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type badLines struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
counts map[string]int
|
|
|
|
|
recent []api.ProtocolError
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newBadLines() *badLines {
|
|
|
|
|
return &badLines{counts: map[string]int{}}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *badLines) add(vendor, sessionID, reason string, line []byte, at time.Time) {
|
|
|
|
|
raw := line
|
|
|
|
|
if len(raw) > badLineBytes {
|
|
|
|
|
raw = raw[:badLineBytes]
|
|
|
|
|
}
|
|
|
|
|
e := api.ProtocolError{
|
|
|
|
|
Vendor: vendor,
|
|
|
|
|
SessionId: sessionID,
|
|
|
|
|
At: at.UTC(),
|
|
|
|
|
Reason: reason,
|
|
|
|
|
Raw: string(raw),
|
|
|
|
|
Size: len(line),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
b.mu.Lock()
|
|
|
|
|
defer b.mu.Unlock()
|
|
|
|
|
b.counts[vendor]++
|
|
|
|
|
if len(b.recent) < keptBadLines {
|
|
|
|
|
b.recent = append(b.recent, e)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
copy(b.recent, b.recent[1:])
|
|
|
|
|
b.recent[len(b.recent)-1] = e
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *badLines) snapshot() api.ProtocolErrors {
|
|
|
|
|
b.mu.Lock()
|
|
|
|
|
defer b.mu.Unlock()
|
|
|
|
|
out := api.ProtocolErrors{Counts: map[string]int{}, Recent: []api.ProtocolError{}}
|
|
|
|
|
for v, n := range b.counts {
|
|
|
|
|
out.Counts[v] = n
|
|
|
|
|
}
|
|
|
|
|
for i := len(b.recent) - 1; i >= 0; i-- {
|
|
|
|
|
out.Recent = append(out.Recent, b.recent[i])
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) recordBadLine(l *live, line []byte, reason string) {
|
|
|
|
|
m.bad.add(string(l.vendor), l.id, reason, line, time.Now())
|
|
|
|
|
m.store.Broadcast(api.Event{Kind: "admin.health", Actor: "byre", Payload: map[string]any{}})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isDecodeError(err error) bool {
|
|
|
|
|
var syntax *json.SyntaxError
|
|
|
|
|
var typ *json.UnmarshalTypeError
|
|
|
|
|
return errors.As(err, &syntax) || errors.As(err, &typ)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) ProtocolErrors() api.ProtocolErrors {
|
|
|
|
|
return m.bad.snapshot()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Health struct {
|
|
|
|
|
Processes []api.AgentProcess
|
|
|
|
|
Zombies []api.Zombie
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type procInfo struct {
|
|
|
|
|
sessionID string
|
|
|
|
|
taskID string
|
|
|
|
|
vendor api.AgentSessionVendor
|
|
|
|
|
pid int
|
|
|
|
|
state api.AgentSessionState
|
|
|
|
|
lastLine time.Time
|
|
|
|
|
exited bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) Health(ctx context.Context) (Health, error) {
|
2026-10-19 19:01:24 -07:00
|
|
|
running, err := m.store.SessionsInState(ctx, api.AgentSessionStateRunning)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Health{}, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-23 11:22:16 -07:00
|
|
|
children, err := claudeChildren(os.Getpid())
|
2026-10-19 19:01:24 -07:00
|
|
|
if err != nil {
|
|
|
|
|
return Health{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Snapshot live and starting after running and children, so a start that shows in either is already registered.
|
2026-10-14 22:35:53 -07:00
|
|
|
m.mu.Lock()
|
|
|
|
|
all := []*live{}
|
|
|
|
|
for _, l := range m.live {
|
|
|
|
|
all = append(all, l)
|
|
|
|
|
}
|
2026-10-19 19:01:24 -07:00
|
|
|
starting := map[string]bool{}
|
|
|
|
|
for id := range m.starting {
|
|
|
|
|
starting[id] = true
|
|
|
|
|
}
|
2026-10-14 22:35:53 -07:00
|
|
|
m.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
procs := []procInfo{}
|
|
|
|
|
for _, l := range all {
|
|
|
|
|
sess, err := m.store.GetSession(ctx, l.id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Health{}, err
|
|
|
|
|
}
|
2026-10-23 16:31:34 -07:00
|
|
|
pid := l.sess.Pid()
|
2026-10-14 22:35:53 -07:00
|
|
|
procs = append(procs, procInfo{
|
|
|
|
|
sessionID: l.id,
|
|
|
|
|
taskID: l.taskID,
|
|
|
|
|
vendor: l.vendor,
|
|
|
|
|
pid: pid,
|
|
|
|
|
state: sess.State,
|
2026-10-23 16:31:34 -07:00
|
|
|
lastLine: l.sess.LastLine(),
|
|
|
|
|
exited: l.sess.Exited() || !pidAlive(pid),
|
2026-10-14 22:35:53 -07:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-19 19:01:24 -07:00
|
|
|
return assess(time.Now(), procs, running, children, starting), nil
|
2026-10-14 22:35:53 -07:00
|
|
|
}
|
|
|
|
|
|
2026-10-19 19:01:24 -07:00
|
|
|
func assess(now time.Time, procs []procInfo, running []api.AgentSession, children []int, starting map[string]bool) Health {
|
2026-10-14 22:35:53 -07:00
|
|
|
slices.SortFunc(procs, func(a, b procInfo) int { return strings.Compare(a.sessionID, b.sessionID) })
|
|
|
|
|
|
|
|
|
|
h := Health{Processes: []api.AgentProcess{}, Zombies: []api.Zombie{}}
|
|
|
|
|
bySession := map[string]procInfo{}
|
|
|
|
|
tracked := map[int]bool{}
|
|
|
|
|
|
|
|
|
|
for _, p := range procs {
|
|
|
|
|
bySession[p.sessionID] = p
|
|
|
|
|
health := api.HealthOk
|
|
|
|
|
switch {
|
|
|
|
|
case p.exited:
|
|
|
|
|
health = api.HealthExited
|
|
|
|
|
case p.state == api.AgentSessionStateRunning && now.Sub(p.lastLine) > StuckAfter:
|
|
|
|
|
health = api.HealthStuck
|
|
|
|
|
}
|
|
|
|
|
h.Processes = append(h.Processes, api.AgentProcess{
|
|
|
|
|
SessionId: p.sessionID,
|
|
|
|
|
TaskId: p.taskID,
|
|
|
|
|
Vendor: string(p.vendor),
|
|
|
|
|
Pid: p.pid,
|
|
|
|
|
State: string(p.state),
|
|
|
|
|
LastEventAt: p.lastLine.UTC(),
|
|
|
|
|
Health: health,
|
|
|
|
|
})
|
|
|
|
|
if p.exited {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
tracked[p.pid] = true
|
|
|
|
|
if p.state == api.AgentSessionStateCompleted {
|
|
|
|
|
h.Zombies = append(h.Zombies, api.Zombie{
|
|
|
|
|
Kind: api.ProcessWithoutSession,
|
|
|
|
|
Detail: fmt.Sprintf("session is completed but its process %d is still alive", p.pid),
|
|
|
|
|
SessionId: &p.sessionID,
|
|
|
|
|
TaskId: &p.taskID,
|
|
|
|
|
Pid: &p.pid,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, sess := range running {
|
|
|
|
|
p, ok := bySession[sess.Id]
|
2026-10-19 19:01:24 -07:00
|
|
|
if (ok && !p.exited) || starting[sess.Id] {
|
2026-10-14 22:35:53 -07:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
detail := "no agent process"
|
|
|
|
|
if ok {
|
|
|
|
|
detail = fmt.Sprintf("process %d has exited", p.pid)
|
|
|
|
|
}
|
|
|
|
|
h.Zombies = append(h.Zombies, api.Zombie{
|
|
|
|
|
Kind: api.RunningWithoutProcess,
|
|
|
|
|
Detail: detail,
|
|
|
|
|
SessionId: &sess.Id,
|
|
|
|
|
TaskId: &sess.TaskId,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, pid := range children {
|
2026-10-19 19:01:24 -07:00
|
|
|
if tracked[pid] || len(starting) > 0 {
|
2026-10-14 22:35:53 -07:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
h.Zombies = append(h.Zombies, api.Zombie{
|
|
|
|
|
Kind: api.ProcessWithoutSession,
|
|
|
|
|
Detail: fmt.Sprintf("claude process %d is a child of byre but belongs to no session", pid),
|
|
|
|
|
Pid: &pid,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func pidAlive(pid int) bool {
|
|
|
|
|
err := syscall.Kill(pid, 0)
|
|
|
|
|
return err == nil || errors.Is(err, syscall.EPERM)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-23 11:22:16 -07:00
|
|
|
func claudeChildren(parent int) ([]int, error) {
|
|
|
|
|
procs, err := unix.SysctlKinfoProcSlice("kern.proc.all")
|
2026-10-14 22:35:53 -07:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list processes: %w", err)
|
|
|
|
|
}
|
|
|
|
|
pids := []int{}
|
2026-10-23 11:22:16 -07:00
|
|
|
for _, p := range procs {
|
|
|
|
|
if int(p.Eproc.Ppid) != parent {
|
2026-10-14 22:35:53 -07:00
|
|
|
continue
|
|
|
|
|
}
|
2026-10-23 11:22:16 -07:00
|
|
|
pid := int(p.Proc.P_pid)
|
|
|
|
|
// p_comm holds the resolved binary's name (claude's is its version), so match the path it was started by.
|
|
|
|
|
args, err := unix.SysctlRaw("kern.procargs2", pid)
|
|
|
|
|
if err != nil || len(args) < 4 {
|
2026-10-14 22:35:53 -07:00
|
|
|
continue
|
|
|
|
|
}
|
2026-10-23 11:22:16 -07:00
|
|
|
path, _, _ := strings.Cut(string(args[4:]), "\x00")
|
|
|
|
|
if filepath.Base(path) != "claude" {
|
2026-10-14 22:35:53 -07:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
pids = append(pids, pid)
|
|
|
|
|
}
|
2026-10-23 11:22:16 -07:00
|
|
|
return pids, nil
|
2026-10-14 22:35:53 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func problems(h Health) []string {
|
|
|
|
|
out := []string{}
|
|
|
|
|
for _, p := range h.Processes {
|
|
|
|
|
if p.Health != api.HealthOk {
|
|
|
|
|
out = append(out, fmt.Sprintf("session %s process %d is %s", p.SessionId, p.Pid, p.Health))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, z := range h.Zombies {
|
|
|
|
|
id := ""
|
|
|
|
|
if z.SessionId != nil {
|
|
|
|
|
id = "session " + *z.SessionId + ": "
|
|
|
|
|
}
|
|
|
|
|
out = append(out, fmt.Sprintf("%s: %s%s", z.Kind, id, z.Detail))
|
|
|
|
|
}
|
|
|
|
|
slices.Sort(out)
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) Watch() {
|
|
|
|
|
t := time.NewTicker(healthInterval)
|
|
|
|
|
defer t.Stop()
|
|
|
|
|
|
|
|
|
|
seen := []string{}
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-m.watchStop:
|
|
|
|
|
return
|
|
|
|
|
case <-t.C:
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h, err := m.Health(context.Background())
|
|
|
|
|
if err != nil {
|
|
|
|
|
slog.Error("check agent health", "err", err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-10-17 07:24:08 -07:00
|
|
|
m.stopStuckReviewers(context.Background(), h)
|
2026-10-14 22:35:53 -07:00
|
|
|
|
|
|
|
|
now := problems(h)
|
|
|
|
|
if slices.Equal(now, seen) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, p := range now {
|
|
|
|
|
if !slices.Contains(seen, p) {
|
|
|
|
|
slog.Warn("agent health problem", "problem", p)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
seen = now
|
|
|
|
|
m.store.Broadcast(api.Event{Kind: "admin.health", Actor: "byre", Payload: map[string]any{}})
|
|
|
|
|
}
|
|
|
|
|
}
|