Show agent liveness, stuck/zombie detection and parse errors in admin

This commit is contained in:
Ian Gulliver
2026-10-14 22:35:53 -07:00
parent 33e682a9d5
commit 1dc09c77e0
21 changed files with 1072 additions and 114 deletions
+58 -3
View File
@@ -15,6 +15,7 @@ import (
"os"
"os/exec"
"sync"
"sync/atomic"
"time"
"g.fc.run/misc/byre/internal/mcp"
@@ -39,6 +40,21 @@ type Options struct {
Hooks map[string][]HookMatcher
MCPServers []*mcp.Server
Transcript io.Writer
OnBadLine func(line []byte, reason string)
}
var knownTypes = map[string]bool{
"assistant": true,
"user": true,
"result": true,
"system": true,
"stream_event": true,
"tool_progress": true,
"rate_limit_event": true,
"control_request": true,
"control_response": true,
"control_cancel_request": true,
"keep_alive": true,
}
type Event struct {
@@ -175,6 +191,9 @@ type Session struct {
idMu sync.Mutex
id string
onBadLine func(line []byte, reason string)
lastLine atomic.Int64
}
type controlResult struct {
@@ -200,7 +219,9 @@ func Start(ctx context.Context, opts Options) (*Session, error) {
hooks: map[string]HookFunc{},
servers: map[string]*mcp.Server{},
id: initialID(opts),
onBadLine: opts.OnBadLine,
}
s.lastLine.Store(time.Now().UnixNano())
args, err := s.buildArgs(opts)
if err != nil {
@@ -355,6 +376,23 @@ func (s *Session) Done() <-chan struct{} {
return s.done
}
func (s *Session) Pid() int {
return s.cmd.Process.Pid
}
func (s *Session) LastLine() time.Time {
return time.Unix(0, s.lastLine.Load())
}
func (s *Session) Exited() bool {
select {
case <-s.done:
return true
default:
return false
}
}
func (s *Session) ID() string {
s.idMu.Lock()
defer s.idMu.Unlock()
@@ -493,8 +531,14 @@ func (s *Session) readLoop(stdout io.Reader) {
r := bufio.NewReader(stdout)
for {
line, err := r.ReadBytes('\n')
if len(line) > 1 && line[0] == '{' {
s.handleLine(line[:len(line)-1])
line = bytes.TrimRight(line, "\r\n")
if len(line) > 0 {
s.lastLine.Store(time.Now().UnixNano())
}
if len(line) > 0 && line[0] == '{' {
s.handleLine(line)
} else if len(line) > 0 {
s.badLine(line, "not a JSON object")
}
if err != nil {
if err != io.EOF {
@@ -510,10 +554,14 @@ func (s *Session) handleLine(line []byte) {
env := envelope{}
err := json.Unmarshal(line, &env)
if err != nil {
slog.Error("parse claude message", "err", err, "line", string(line))
s.badLine(line, err.Error())
return
}
if !knownTypes[env.Type] {
s.badLine(line, fmt.Sprintf("unrecognised type %q", env.Type))
}
s.writeMu.Lock()
if env.Type == "control_response" {
s.learnScrub(env.Response)
@@ -545,6 +593,13 @@ func (s *Session) handleLine(line []byte) {
s.events <- Event{Type: env.Type, Subtype: env.Subtype, Raw: append(json.RawMessage{}, line...), Read: read}
}
func (s *Session) badLine(line []byte, reason string) {
slog.Warn("bad claude line", "reason", reason, "line", string(line))
if s.onBadLine != nil {
s.onBadLine(line, reason)
}
}
func (s *Session) learnScrub(raw json.RawMessage) {
resp := struct {
Response struct {