From 2111f19e9fcf580b273c53dc651d5577f3a0b9fc Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Fri, 27 Feb 2026 11:46:49 +0100 Subject: [PATCH] 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 --- README.md | 17 +++++++++++++ cmd/anvilsrv/main.go | 29 +++++++++++++++------ internal/backend/tmux/session.go | 11 ++++---- internal/backend/tmux/tmux.go | 13 +++++----- internal/debug/debug.go | 6 +++-- internal/logging/logger.go | 43 ++++++++++++++++++++++++++++++++ internal/p9/beads.go | 15 +++++------ internal/p9/server.go | 8 +++--- internal/p9/skills.go | 12 +++++++-- internal/session/manager.go | 23 ++++++++++++++--- 10 files changed, 142 insertions(+), 35 deletions(-) create mode 100644 internal/logging/logger.go diff --git a/README.md b/README.md index 5acd6e4..dffcbbe 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/anvilsrv/main.go b/cmd/anvilsrv/main.go index 1393f6a..c02b6e6 100644 --- a/cmd/anvilsrv/main.go +++ b/cmd/anvilsrv/main.go @@ -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) } diff --git a/internal/backend/tmux/session.go b/internal/backend/tmux/session.go index 8d55a18..82361b4 100644 --- a/internal/backend/tmux/session.go +++ b/internal/backend/tmux/session.go @@ -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") } diff --git a/internal/backend/tmux/tmux.go b/internal/backend/tmux/tmux.go index 7df0560..17fd601 100644 --- a/internal/backend/tmux/tmux.go +++ b/internal/backend/tmux/tmux.go @@ -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") } } diff --git a/internal/debug/debug.go b/internal/debug/debug.go index 159e701..e85315a 100644 --- a/internal/debug/debug.go +++ b/internal/debug/debug.go @@ -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...) } } diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 0000000..a769f05 --- /dev/null +++ b/internal/logging/logger.go @@ -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 +} diff --git a/internal/p9/beads.go b/internal/p9/beads.go index 2f61df7..34a9181 100644 --- a/internal/p9/beads.go +++ b/internal/p9/beads.go @@ -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 ") } - 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 '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 { diff --git a/internal/p9/server.go b/internal/p9/server.go index f501e9f..9447e1a 100644 --- a/internal/p9/server.go +++ b/internal/p9/server.go @@ -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 } } diff --git a/internal/p9/skills.go b/internal/p9/skills.go index 4f8967b..55d4a6a 100644 --- a/internal/p9/skills.go +++ b/internal/p9/skills.go @@ -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} diff --git a/internal/session/manager.go b/internal/session/manager.go index 920174c..83d2f3c 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -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)) + } } }