Add zap logging to ~/.config/anvillm/logs with panic recovery

- Created internal/logging package with zap logger
- Added panic recovery to anvilsrv main loop, monitor goroutine, and mail processing
- Replaced log.Printf/Fatal with zap in debug, p9, tmux packages
- Fixed beads race condition: clear assignee before closing issue
- All errors and crashes now logged to ~/.config/anvillm/logs
This commit is contained in:
Levi Neely 2026-02-27 11:46:49 +01:00
parent f252aa9452
commit 2111f19e9f
10 changed files with 142 additions and 35 deletions

View File

@ -94,6 +94,23 @@ NAMESPACE=/tmp/ns.$USER.:1 Assist
| `CLAUDE_AGENT_NAME` | `anvillm-agent` | Claude agent configuration name |
| `KIRO_API_KEY` | — | Kiro API key (optional if using `kiro-cli login`) |
| `ANVILLM_OLLAMA_MODEL` | `qwen3:8b` | Ollama model to use for ollama backend |
| `ANVILLM_SKILLS_DIR` | `$CLAUDE_CONFIG_DIR/skills:~/.kiro/skills:~/.config/anvillm/skills` | Colon-separated skill directories (searched in order) |
### Skills System
Skills are loaded from multiple directories via the `agent/skills` 9pfs. By default, searches:
1. `$CLAUDE_CONFIG_DIR/skills` (if `CLAUDE_CONFIG_DIR` set)
2. `~/.kiro/skills`
3. `~/.config/anvillm/skills`
Override with `ANVILLM_SKILLS_DIR` (colon-separated paths). Skills are organized by intent (virtual directories) and discovered via `SKILL.md` front-matter.
**Usage:**
```sh
9p ls agent/skills # list intents
9p read agent/skills/help # search index
9p read agent/skills/tasks/beads/SKILL.md
```
### Sandbox Config Templates

View File

@ -5,6 +5,7 @@ import (
"anvillm/internal/backend"
"anvillm/internal/backend/tmux"
"anvillm/internal/backends"
"anvillm/internal/logging"
"anvillm/internal/p9"
"anvillm/internal/session"
"context"
@ -20,6 +21,7 @@ import (
"9fans.net/go/plan9/client"
bd "github.com/steveyegge/beads"
"go.uber.org/zap"
)
func getPidFilePath() string {
@ -79,6 +81,12 @@ func usage() {
}
func start(daemonize bool) {
// Initialize logging first
if err := logging.Init(); err != nil {
log.Fatalf("Failed to initialize logging: %v", err)
}
defer logging.Logger().Sync()
// Set CLAUDE_CONFIG_DIR if not already set in the environment
if os.Getenv("CLAUDE_CONFIG_DIR") == "" {
os.Setenv("CLAUDE_CONFIG_DIR", filepath.Join(os.Getenv("HOME"), ".config", "anvillm", "claude"))
@ -89,7 +97,7 @@ func start(daemonize bool) {
// Check if already running before daemonizing
if existingPid := readPidFile(); existingPid != 0 {
if isProcessRunning(existingPid) {
log.Fatalf("agent already running")
logging.Logger().Fatal("agent already running", zap.Int("pid", existingPid))
}
// Stale PID file
os.Remove(pidFile)
@ -181,7 +189,10 @@ func start(daemonize bool) {
// Cleanup tmux sessions on exit
defer func() {
log.Println("Shutting down: cleaning up tmux sessions")
if r := recover(); r != nil {
logging.Logger().Fatal("panic in main", zap.Any("panic", r))
}
logging.Logger().Info("shutting down: cleaning up tmux sessions")
for _, b := range backendMap {
if tmuxBackend, ok := b.(*tmux.Backend); ok {
tmuxBackend.Cleanup()
@ -196,7 +207,7 @@ func start(daemonize bool) {
}
beadsStore, err := bd.OpenFromConfig(context.Background(), beadsDir)
if err != nil {
log.Printf("Warning: failed to initialize beads store: %v", err)
logging.Logger().Warn("failed to initialize beads store", zap.Error(err))
beadsStore = nil // Continue without beads support
}
if beadsStore != nil {
@ -206,19 +217,23 @@ func start(daemonize bool) {
// Start 9P server
srv, err := p9.NewServer(mgr, beadsStore)
if err != nil {
log.Fatal(err)
logging.Logger().Fatal("failed to start 9P server", zap.Error(err))
}
defer srv.Close()
// Wire up event bus to session manager
mgr.SetEventBus(srv.Events())
log.Println("anvilsrv started successfully")
log.Printf("9P server listening on %s", srv.SocketPath())
logging.Logger().Info("anvilsrv started successfully", zap.String("socket", srv.SocketPath()))
// Start background monitor for self-healing
stopMonitor := make(chan struct{})
go func() {
defer func() {
if r := recover(); r != nil {
logging.Logger().Error("panic in monitor goroutine", zap.Any("panic", r))
}
}()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
@ -237,7 +252,7 @@ func start(daemonize bool) {
// Wait for termination signal
sig := <-sigChan
log.Printf("Received signal %v, shutting down", sig)
logging.Logger().Info("received signal, shutting down", zap.String("signal", sig.String()))
close(stopMonitor)
}

View File

@ -3,6 +3,7 @@ package tmux
import (
"anvillm/internal/backend"
"anvillm/internal/debug"
"anvillm/internal/logging"
"anvillm/internal/sandbox"
"bytes"
"context"
@ -13,6 +14,8 @@ import (
"sync"
"syscall"
"time"
"go.uber.org/zap"
)
// Session implements backend.Session for tmux-based tools
@ -343,8 +346,7 @@ func (s *Session) waitForReady(ctx context.Context, timeout time.Duration) error
hasPrompt := strings.Contains(output, "!>")
if !hasPrompt && (strings.Contains(output, "Failed to apply sandbox") ||
strings.Contains(output, "missing kernel Landlock support")) {
fmt.Fprintf(os.Stderr, "Error: backend failed to launch\n")
fmt.Fprintf(os.Stderr, "Backend output:\n%s\n", output)
logging.Logger().Error("backend failed to launch", zap.String("output", output))
return fmt.Errorf("backend launch failed")
}
@ -376,10 +378,9 @@ func (s *Session) waitForReady(ctx context.Context, timeout time.Duration) error
output := s.output.String()
debug.Log("[session %s] timeout, current output: %s", s.id, output)
if output != "" {
fmt.Fprintf(os.Stderr, "Error: timeout waiting for backend to be ready\n")
fmt.Fprintf(os.Stderr, "Backend output:\n%s\n", output)
logging.Logger().Error("timeout waiting for backend", zap.String("session", s.id), zap.String("output", output))
} else {
fmt.Fprintf(os.Stderr, "Error: timeout waiting for backend to be ready (no output received)\n")
logging.Logger().Error("timeout waiting for backend (no output)", zap.String("session", s.id))
}
return fmt.Errorf("timeout waiting for ready")
}

View File

@ -4,6 +4,7 @@ package tmux
import (
"anvillm/internal/backend"
"anvillm/internal/debug"
"anvillm/internal/logging"
"anvillm/internal/sandbox"
"context"
"crypto/rand"
@ -248,15 +249,15 @@ func (b *Backend) CreateSession(ctx context.Context, opts backend.SessionOptions
}
if !sandbox.IsAvailable() {
if sandboxCfg.General.BestEffort {
fmt.Fprintf(os.Stderr, "WARNING: landrun not available, running UNSANDBOXED (best-effort mode)\n")
fmt.Fprintf(os.Stderr, "WARNING: No filesystem or network restrictions will be enforced!\n")
fmt.Fprintf(os.Stderr, "Install landrun: go install github.com/landlock-lsm/landrun@latest\n")
logging.Logger().Warn("landrun not available, running UNSANDBOXED (best-effort mode)")
logging.Logger().Warn("no filesystem or network restrictions will be enforced")
logging.Logger().Warn("install landrun: go install github.com/landlock-lsm/landrun@latest")
} else {
os.Remove(fifoPath)
killWindow(b.tmuxSession, windowName)
fmt.Fprintf(os.Stderr, "ERROR: landrun not available and sandboxing is required\n")
fmt.Fprintf(os.Stderr, "Install landrun: go install github.com/landlock-lsm/landrun@latest\n")
fmt.Fprintf(os.Stderr, "Or enable best_effort mode in ~/.config/anvillm/global.yaml (NOT RECOMMENDED)\n")
logging.Logger().Error("landrun not available and sandboxing is required")
logging.Logger().Error("install landrun: go install github.com/landlock-lsm/landrun@latest")
logging.Logger().Error("or enable best_effort mode in ~/.config/anvillm/global.yaml (NOT RECOMMENDED)")
return nil, fmt.Errorf("landrun not available")
}
}

View File

@ -1,6 +1,8 @@
package debug
import "log"
import (
"anvillm/internal/logging"
)
// Enabled controls whether debug logs are printed
var Enabled = false
@ -8,6 +10,6 @@ var Enabled = false
// Log prints a log message only when debug mode is enabled
func Log(format string, args ...interface{}) {
if Enabled {
log.Printf(format, args...)
logging.Logger().Sugar().Infof(format, args...)
}
}

View File

@ -0,0 +1,43 @@
package logging
import (
"os"
"path/filepath"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var logger *zap.Logger
func Init() error {
configDir := os.Getenv("HOME")
if configDir == "" {
configDir = "/tmp"
}
logDir := filepath.Join(configDir, ".config", "anvillm")
if err := os.MkdirAll(logDir, 0755); err != nil {
return err
}
logPath := filepath.Join(logDir, "logs")
config := zap.NewProductionEncoderConfig()
config.EncodeTime = zapcore.ISO8601TimeEncoder
fileEncoder := zapcore.NewJSONEncoder(config)
logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
core := zapcore.NewCore(fileEncoder, zapcore.AddSync(logFile), zapcore.InfoLevel)
logger = zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
return nil
}
func Logger() *zap.Logger {
if logger == nil {
logger = zap.NewNop()
}
return logger
}

View File

@ -1,16 +1,17 @@
package p9
import (
"anvillm/internal/logging"
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
bd "github.com/steveyegge/beads"
"go.uber.org/zap"
)
// Approval gate statuses extend the beads status set locally.
@ -170,7 +171,7 @@ func (b *BeadsFS) readList() ([]byte, error) {
result[i].Issue = issue
blockers, err := b.getBlockers(issue.ID)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to get blockers for %s: %v\n", issue.ID, err)
logging.Logger().Warn("failed to get blockers", zap.String("issue", issue.ID), zap.Error(err))
} else if len(blockers) > 0 {
result[i].Blockers = blockers
}
@ -470,7 +471,7 @@ func (b *BeadsFS) executeCtl(cmd string) error {
isIdea := strings.HasPrefix(strings.ToUpper(title), "IDEA:")
if !noLint && !isIdea && description != "" {
for _, w := range lintDescription(description) {
fmt.Fprintf(os.Stderr, "lint warning [%s]: %s\n", title, w)
logging.Logger().Warn("lint warning", zap.String("title", title), zap.String("warning", w))
}
}
@ -516,19 +517,19 @@ func (b *BeadsFS) executeCtl(cmd string) error {
if len(args) < 1 {
return fmt.Errorf("usage: complete <bead-id>")
}
if err := b.store.CloseIssue(b.ctx, args[0], "completed", actor, ""); err != nil {
if err := b.store.UpdateIssue(b.ctx, args[0], map[string]any{"assignee": ""}, actor); err != nil {
return err
}
return b.store.UpdateIssue(b.ctx, args[0], map[string]any{"assignee": ""}, actor)
return b.store.CloseIssue(b.ctx, args[0], "completed", actor, "")
case "fail":
if len(args) < 2 {
return fmt.Errorf("usage: fail <bead-id> 'reason'")
}
if err := b.store.CloseIssue(b.ctx, args[0], args[1], actor, ""); err != nil {
if err := b.store.UpdateIssue(b.ctx, args[0], map[string]any{"assignee": ""}, actor); err != nil {
return err
}
return b.store.UpdateIssue(b.ctx, args[0], map[string]any{"assignee": ""}, actor)
return b.store.CloseIssue(b.ctx, args[0], args[1], actor, "")
case "dep", "add-dep":
if len(args) < 2 {

View File

@ -5,6 +5,7 @@ import (
"anvillm/internal/backend"
"anvillm/internal/backend/tmux"
"anvillm/internal/eventbus"
"anvillm/internal/logging"
"anvillm/internal/mailbox"
"anvillm/internal/session"
"context"
@ -23,6 +24,7 @@ import (
"github.com/google/uuid"
bd "github.com/steveyegge/beads"
"go.uber.org/zap"
"9fans.net/go/plan9"
"9fans.net/go/plan9/client"
@ -280,7 +282,7 @@ func (s *Server) acceptLoop() {
conn, err := s.listener.Accept()
if err != nil {
if !errors.Is(err, net.ErrClosed) {
fmt.Fprintf(os.Stderr, "accept: %v\n", err)
logging.Logger().Error("accept error", zap.Error(err))
}
return
}
@ -308,14 +310,14 @@ func (s *Server) serve(conn net.Conn) {
fc, err := plan9.ReadFcall(conn)
if err != nil {
if err != io.EOF {
fmt.Fprintf(os.Stderr, "read: %v\n", err)
logging.Logger().Error("read error", zap.Error(err))
}
return
}
rfc := s.handle(cs, fc)
if err := plan9.WriteFcall(conn, rfc); err != nil {
fmt.Fprintf(os.Stderr, "write: %v\n", err)
logging.Logger().Error("write error", zap.Error(err))
return
}
}

View File

@ -32,9 +32,17 @@ func NewSkillsFS() *SkillsFS {
if envDirs := os.Getenv("ANVILLM_SKILLS_DIR"); envDirs != "" {
dirs = strings.Split(envDirs, ":")
} else {
// Default fallback
// Default: Claude, Kiro, AnviLLM config directories
homeDir, _ := os.UserHomeDir()
dirs = []string{filepath.Join(homeDir, ".config/anvillm/skills")}
var defaults []string
if claudeDir := os.Getenv("CLAUDE_CONFIG_DIR"); claudeDir != "" {
defaults = append(defaults, filepath.Join(claudeDir, "skills"))
}
defaults = append(defaults,
filepath.Join(homeDir, ".kiro/skills"),
filepath.Join(homeDir, ".config/anvillm/skills"),
)
dirs = defaults
}
return &SkillsFS{skillsDirs: dirs}

View File

@ -5,13 +5,15 @@ import (
"anvillm/internal/backend"
"anvillm/internal/backend/tmux"
"anvillm/internal/eventbus"
"anvillm/internal/logging"
"anvillm/internal/mailbox"
"context"
"fmt"
"os"
"sort"
"sync"
"time"
"go.uber.org/zap"
)
// Manager holds all active sessions
@ -176,6 +178,11 @@ func (m *Manager) Stop() {
// mailProcessingLoop processes mailboxes every 5 seconds
func (m *Manager) mailProcessingLoop() {
defer m.wg.Done()
defer func() {
if r := recover(); r != nil {
logging.Logger().Error("panic in mailProcessingLoop", zap.Any("panic", r))
}
}()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
@ -192,6 +199,12 @@ func (m *Manager) mailProcessingLoop() {
// processMailboxes handles outbox delivery and inbox processing
func (m *Manager) processMailboxes() {
defer func() {
if r := recover(); r != nil {
logging.Logger().Error("panic in processMailboxes", zap.Any("panic", r))
}
}()
m.mu.RLock()
sessions := make([]backend.Session, 0, len(m.sessions))
for _, sess := range m.sessions {
@ -205,6 +218,7 @@ func (m *Manager) processMailboxes() {
for m.mailManager.HasOutbox(senderID) {
msg, err := m.mailManager.PeekOutbox(senderID)
if err != nil {
logging.Logger().Error("failed to peek outbox", zap.String("sender", senderID), zap.Error(err))
break
}
// Deliver first, then remove only if successful
@ -216,7 +230,7 @@ func (m *Manager) processMailboxes() {
}
msg.Metadata["error"] = err.Error()
m.mailManager.MoveToCompleted(senderID, msg)
fmt.Fprintf(os.Stderr, "Message undeliverable to %s: %v\n", msg.To, err)
logging.Logger().Warn("message undeliverable", zap.String("to", msg.To), zap.Error(err))
} else {
// Remove only after successful delivery
m.mailManager.RemoveFromOutbox(senderID)
@ -250,7 +264,10 @@ func (m *Manager) processMailboxes() {
// Prompt agent to check inbox
ctx := context.Background()
sess.Send(ctx, fmt.Sprintf("You have a new message. Read it using read_inbox (agent_id=%s) and respond appropriately.", sess.ID()))
_, err := sess.Send(ctx, fmt.Sprintf("You have a new message. Read it using read_inbox (agent_id=%s) and respond appropriately.", sess.ID()))
if err != nil {
logging.Logger().Error("failed to prompt agent", zap.String("session", sess.ID()), zap.Error(err))
}
}
}