feat: Add tmux crash detection hooks (gt-i9s7o)
- Add SetPaneDiedHook to tmux package for crash detection - Add gt log crash subcommand for hook callback - Set pane-died hook when starting polecat sessions - Distinguish exit types: 0=done, 130=kill (Ctrl+C), other=crash - Rename townlog/townlog.go to townlog/logger.go 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
323
internal/townlog/logger.go
Normal file
323
internal/townlog/logger.go
Normal file
@@ -0,0 +1,323 @@
|
||||
// Package townlog provides centralized logging for Gas Town agent lifecycle events.
|
||||
package townlog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventType represents the type of agent lifecycle event.
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
// EventSpawn indicates a new agent was created.
|
||||
EventSpawn EventType = "spawn"
|
||||
// EventWake indicates an agent was resumed.
|
||||
EventWake EventType = "wake"
|
||||
// EventNudge indicates a message was injected into an agent.
|
||||
EventNudge EventType = "nudge"
|
||||
// EventHandoff indicates an agent handed off to a fresh session.
|
||||
EventHandoff EventType = "handoff"
|
||||
// EventDone indicates an agent finished its work.
|
||||
EventDone EventType = "done"
|
||||
// EventCrash indicates an agent exited unexpectedly.
|
||||
EventCrash EventType = "crash"
|
||||
// EventKill indicates an agent was killed intentionally.
|
||||
EventKill EventType = "kill"
|
||||
)
|
||||
|
||||
// Event represents a single agent lifecycle event.
|
||||
type Event struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Type EventType `json:"type"`
|
||||
Agent string `json:"agent"` // e.g., "gastown/crew/max" or "gastown/polecats/Toast"
|
||||
Context string `json:"context,omitempty"` // Additional context (issue ID, error message, etc.)
|
||||
}
|
||||
|
||||
// Logger handles writing events to the town log file.
|
||||
type Logger struct {
|
||||
logPath string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// logDir returns the directory for town logs.
|
||||
func logDir(townRoot string) string {
|
||||
return filepath.Join(townRoot, "logs")
|
||||
}
|
||||
|
||||
// logPath returns the path to the town log file.
|
||||
func logPath(townRoot string) string {
|
||||
return filepath.Join(logDir(townRoot), "town.log")
|
||||
}
|
||||
|
||||
// NewLogger creates a new Logger for the given town root.
|
||||
func NewLogger(townRoot string) *Logger {
|
||||
return &Logger{
|
||||
logPath: logPath(townRoot),
|
||||
}
|
||||
}
|
||||
|
||||
// LogEvent logs a single event to the town log.
|
||||
func (l *Logger) LogEvent(event Event) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
// Ensure log directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(l.logPath), 0755); err != nil {
|
||||
return fmt.Errorf("creating log directory: %w", err)
|
||||
}
|
||||
|
||||
// Open file for appending
|
||||
f, err := os.OpenFile(l.logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening log file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Write human-readable log line
|
||||
line := formatLogLine(event)
|
||||
if _, err := f.WriteString(line + "\n"); err != nil {
|
||||
return fmt.Errorf("writing log line: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Log is a convenience method that creates an Event and logs it.
|
||||
func (l *Logger) Log(eventType EventType, agent, context string) error {
|
||||
return l.LogEvent(Event{
|
||||
Timestamp: time.Now(),
|
||||
Type: eventType,
|
||||
Agent: agent,
|
||||
Context: context,
|
||||
})
|
||||
}
|
||||
|
||||
// formatLogLine formats an event as a human-readable log line.
|
||||
// Format: 2025-12-26 15:30:45 [spawn] gastown/crew/max spawned for gt-xyz
|
||||
func formatLogLine(e Event) string {
|
||||
ts := e.Timestamp.Format("2006-01-02 15:04:05")
|
||||
|
||||
var detail string
|
||||
switch e.Type {
|
||||
case EventSpawn:
|
||||
if e.Context != "" {
|
||||
detail = fmt.Sprintf("spawned for %s", e.Context)
|
||||
} else {
|
||||
detail = "spawned"
|
||||
}
|
||||
case EventWake:
|
||||
detail = "resumed"
|
||||
if e.Context != "" {
|
||||
detail += fmt.Sprintf(" (%s)", e.Context)
|
||||
}
|
||||
case EventNudge:
|
||||
if e.Context != "" {
|
||||
detail = fmt.Sprintf("nudged with %q", truncate(e.Context, 50))
|
||||
} else {
|
||||
detail = "nudged"
|
||||
}
|
||||
case EventHandoff:
|
||||
detail = "handed off"
|
||||
if e.Context != "" {
|
||||
detail += fmt.Sprintf(" (%s)", e.Context)
|
||||
}
|
||||
case EventDone:
|
||||
if e.Context != "" {
|
||||
detail = fmt.Sprintf("completed %s", e.Context)
|
||||
} else {
|
||||
detail = "completed work"
|
||||
}
|
||||
case EventCrash:
|
||||
if e.Context != "" {
|
||||
detail = fmt.Sprintf("exited unexpectedly (%s)", e.Context)
|
||||
} else {
|
||||
detail = "exited unexpectedly"
|
||||
}
|
||||
case EventKill:
|
||||
if e.Context != "" {
|
||||
detail = fmt.Sprintf("killed (%s)", e.Context)
|
||||
} else {
|
||||
detail = "killed"
|
||||
}
|
||||
default:
|
||||
detail = string(e.Type)
|
||||
if e.Context != "" {
|
||||
detail += fmt.Sprintf(" (%s)", e.Context)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s [%s] %s %s", ts, e.Type, e.Agent, detail)
|
||||
}
|
||||
|
||||
// truncate shortens a string to max length with ellipsis.
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// ReadEvents reads all events from the log file.
|
||||
// Useful for filtering and analysis.
|
||||
func ReadEvents(townRoot string) ([]Event, error) {
|
||||
path := logPath(townRoot)
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // No log file yet
|
||||
}
|
||||
return nil, fmt.Errorf("reading log file: %w", err)
|
||||
}
|
||||
|
||||
return ParseLogLines(string(content))
|
||||
}
|
||||
|
||||
// ParseLogLines parses log lines back into Events.
|
||||
// This is the inverse of formatLogLine for filtering.
|
||||
func ParseLogLines(content string) ([]Event, error) {
|
||||
var events []Event
|
||||
lines := splitLines(content)
|
||||
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
event, err := parseLogLine(line)
|
||||
if err != nil {
|
||||
continue // Skip malformed lines
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// parseLogLine parses a single log line into an Event.
|
||||
// Format: 2025-12-26 15:30:45 [spawn] gastown/crew/max spawned for gt-xyz
|
||||
func parseLogLine(line string) (Event, error) {
|
||||
var event Event
|
||||
|
||||
// Parse timestamp (first 19 chars: "2006-01-02 15:04:05")
|
||||
if len(line) < 19 {
|
||||
return event, fmt.Errorf("line too short")
|
||||
}
|
||||
ts, err := time.Parse("2006-01-02 15:04:05", line[:19])
|
||||
if err != nil {
|
||||
return event, fmt.Errorf("parsing timestamp: %w", err)
|
||||
}
|
||||
event.Timestamp = ts
|
||||
|
||||
// Find event type in brackets
|
||||
rest := line[20:] // Skip timestamp and space
|
||||
if len(rest) < 3 || rest[0] != '[' {
|
||||
return event, fmt.Errorf("missing event type")
|
||||
}
|
||||
|
||||
closeBracket := -1
|
||||
for i, c := range rest {
|
||||
if c == ']' {
|
||||
closeBracket = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if closeBracket < 0 {
|
||||
return event, fmt.Errorf("unclosed bracket")
|
||||
}
|
||||
|
||||
event.Type = EventType(rest[1:closeBracket])
|
||||
|
||||
// Rest is " agent details"
|
||||
rest = rest[closeBracket+1:]
|
||||
if len(rest) < 2 || rest[0] != ' ' {
|
||||
return event, fmt.Errorf("missing agent")
|
||||
}
|
||||
rest = rest[1:]
|
||||
|
||||
// Find first space after agent
|
||||
spaceIdx := -1
|
||||
for i, c := range rest {
|
||||
if c == ' ' {
|
||||
spaceIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if spaceIdx < 0 {
|
||||
event.Agent = rest
|
||||
} else {
|
||||
event.Agent = rest[:spaceIdx]
|
||||
// The rest is context info (not worth parsing further)
|
||||
}
|
||||
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
lines = append(lines, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
lines = append(lines, s[start:])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// LogEventJSON writes an event in JSON format for machine parsing.
|
||||
// Returns the JSON representation.
|
||||
func (e Event) JSON() ([]byte, error) {
|
||||
return json.Marshal(e)
|
||||
}
|
||||
|
||||
// TailEvents returns the last n events from the log.
|
||||
func TailEvents(townRoot string, n int) ([]Event, error) {
|
||||
events, err := ReadEvents(townRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(events) <= n {
|
||||
return events, nil
|
||||
}
|
||||
return events[len(events)-n:], nil
|
||||
}
|
||||
|
||||
// FilterEvents returns events matching the filter criteria.
|
||||
type Filter struct {
|
||||
Type EventType // Filter by event type (empty for all)
|
||||
Agent string // Filter by agent prefix (empty for all)
|
||||
Since time.Time // Filter by time (zero for all)
|
||||
}
|
||||
|
||||
// FilterEvents applies a filter to events.
|
||||
func FilterEvents(events []Event, f Filter) []Event {
|
||||
var result []Event
|
||||
for _, e := range events {
|
||||
if f.Type != "" && e.Type != f.Type {
|
||||
continue
|
||||
}
|
||||
if f.Agent != "" && !hasPrefix(e.Agent, f.Agent) {
|
||||
continue
|
||||
}
|
||||
if !f.Since.IsZero() && e.Timestamp.Before(f.Since) {
|
||||
continue
|
||||
}
|
||||
result = append(result, e)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func hasPrefix(s, prefix string) bool {
|
||||
if len(s) < len(prefix) {
|
||||
return false
|
||||
}
|
||||
return s[:len(prefix)] == prefix
|
||||
}
|
||||
Reference in New Issue
Block a user