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:
parent
f252aa9452
commit
2111f19e9f
17
README.md
17
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Reference in New Issue