This repository has been archived on 2026-06-13. You can view files and clone it, but cannot push or open issues or pull requests.
anvillm/main.go

359 lines
9.4 KiB
Go

// anvillm - AnviLLM backend server daemon
package main
import (
"anvillm/internal/backend"
"anvillm/internal/backend/tmux"
"anvillm/internal/backends"
"anvillm/pkg/logging"
"anvillm/internal/maildir"
"anvillm/internal/p9"
"anvillm/internal/session"
"context"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"9fans.net/go/plan9/client"
"go.uber.org/zap"
)
func getPidFilePath() string {
ns := client.Namespace()
if ns == "" {
// Fallback to /tmp if namespace not available
return filepath.Join("/tmp", "anvillm.pid")
}
return filepath.Join(ns, "anvillm.pid")
}
// getNamespaceSuffix extracts the display number from namespace (e.g., "0" from "/tmp/ns.user.:0")
func getNamespaceSuffix() string {
ns := client.Namespace()
if ns == "" {
return ""
}
// Extract :N from /tmp/ns.user.:N
parts := strings.Split(ns, ":")
if len(parts) >= 2 {
suffix := parts[len(parts)-1]
// Remove trailing slash if present
return strings.TrimSuffix(suffix, "/")
}
return ""
}
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
switch os.Args[1] {
case "start":
start(true) // daemonize
case "fgstart":
start(false) // foreground
case "stop":
stop()
case "status":
status()
default:
usage()
os.Exit(1)
}
}
func usage() {
fmt.Fprintf(os.Stderr, "Usage: %s {start|fgstart|stop|status}\n", os.Args[0])
fmt.Fprintf(os.Stderr, "\n")
fmt.Fprintf(os.Stderr, "Commands:\n")
fmt.Fprintf(os.Stderr, " start - Start the anvillm daemon (daemonized)\n")
fmt.Fprintf(os.Stderr, " fgstart - Start in foreground (for debugging)\n")
fmt.Fprintf(os.Stderr, " stop - Stop the running daemon\n")
fmt.Fprintf(os.Stderr, " status - Check daemon status\n")
}
func start(daemonize bool) {
// Initialize logging first
if err := logging.Init(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to initialize logging: %v\n", err)
os.Exit(1)
}
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"))
}
pidFile := getPidFilePath()
// Check if already running before daemonizing
if existingPid := readPidFile(); existingPid != 0 {
if isProcessRunning(existingPid) {
logging.Logger().Fatal("agent already running", zap.Int("pid", existingPid))
}
// Stale PID file
logging.Logger().Warn("removing stale PID file", zap.Int("pid", existingPid))
os.Remove(pidFile)
}
// Daemonize if requested
if daemonize {
// Check if we're already the daemon (via environment variable)
if os.Getenv("ANVILSRV_DAEMON") != "1" {
logging.Logger().Info("daemonizing anvillm")
// Fork the daemon process - use full path to executable
cmd, err := os.Executable()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to get executable path: %v\n", err)
os.Exit(1)
}
args := []string{"fgstart"} // Use fgstart to avoid re-daemonizing
attr := &os.ProcAttr{
Dir: "/",
Env: append(os.Environ(), "ANVILSRV_DAEMON=1"),
Files: []*os.File{nil, nil, nil}, // detach stdin/stdout/stderr
}
proc, err := os.StartProcess(cmd, append([]string{cmd}, args...), attr)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to daemonize: %v\n", err)
os.Exit(1)
}
// Wait a moment to ensure daemon starts and writes PID file
// The daemon will handle its own PID file
proc.Release()
// Give daemon time to start
for i := 0; i < 10; i++ {
if readPidFile() != 0 {
fmt.Fprintf(os.Stderr, "anvillm started successfully\n")
return
}
time.Sleep(100 * time.Millisecond)
}
fmt.Fprintf(os.Stderr, "anvillm started (daemon mode)\n")
return
}
}
// We're the daemon (or running in foreground), proceed with actual startup
pid := os.Getpid()
pidContent := fmt.Sprintf("%d\n", pid)
logging.Logger().Info("starting anvillm", zap.Int("pid", pid), zap.Bool("daemonized", daemonize))
// Try to create PID file exclusively (fails if exists)
f, err := os.OpenFile(pidFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err != nil {
// File exists, check if process is still running
if existingPid := readPidFile(); existingPid != 0 {
if isProcessRunning(existingPid) {
logging.Logger().Fatal("anvillm already running", zap.Int("pid", existingPid))
}
// Stale PID file, remove and retry
logging.Logger().Warn("removing stale PID file on startup", zap.Int("pid", existingPid))
os.Remove(pidFile)
f, err = os.OpenFile(pidFile, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create PID file: %v\n", err)
os.Exit(1)
}
} else {
fmt.Fprintf(os.Stderr, "Failed to create PID file: %v\n", err)
os.Exit(1)
}
}
if _, err := f.WriteString(pidContent); err != nil {
f.Close()
os.Remove(pidFile)
logging.Logger().Fatal("failed to write PID file", zap.Error(err))
}
f.Close()
defer os.Remove(pidFile)
// Setup signal handling
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Create all backends
nsSuffix := getNamespaceSuffix()
logging.Logger().Debug("initializing backends", zap.String("namespace_suffix", nsSuffix))
backendMap := map[string]backend.Backend{
"kiro-cli": backends.NewKiroCLI(nsSuffix),
"claude": backends.NewClaude(nsSuffix),
"ollie": backends.NewOllie(nsSuffix),
"goq": backends.NewGoq(nsSuffix),
}
mgr := session.NewManager(backendMap)
logging.Logger().Info("session manager initialized")
// Cleanup tmux sessions on exit
defer func() {
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()
}
}
}()
// Start 9P server (beads served separately by 9beads)
srv, err := p9.NewServer(mgr)
if err != nil {
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())
// Start maildir writer for message persistence
mailDir := filepath.Join(os.Getenv("HOME"), ".local", "share", "anvillm", "mail")
mdWriter := maildir.New(mailDir, srv.Events())
defer mdWriter.Close()
logging.Logger().Info("anvillm started successfully", zap.String("socket", srv.SocketPath()))
// Setup FUSE mount
mnt := os.Getenv("ANVILLM_9MOUNT")
if mnt == "" {
mnt = filepath.Join(os.Getenv("HOME"), "mnt", "anvillm")
}
var fuseCmd *exec.Cmd
if err := os.MkdirAll(mnt, 0755); err != nil {
logging.Logger().Warn("cannot create mount dir", zap.Error(err))
} else {
fuseCmd = exec.Command("9pfuse", srv.SocketPath(), mnt)
if err := fuseCmd.Start(); err != nil {
logging.Logger().Warn("9pfuse failed", zap.Error(err))
fuseCmd = nil
} else {
logging.Logger().Info("mounted", zap.String("path", mnt))
}
}
defer func() {
if fuseCmd != nil {
exec.Command("fusermount", "-u", mnt).Run()
fuseCmd.Wait()
}
}()
// Start background monitor for self-healing
stopMonitor := make(chan struct{})
logging.Logger().Debug("starting background session monitor")
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 {
select {
case <-ticker.C:
for _, id := range mgr.List() {
if sess := mgr.Get(id); sess != nil {
sess.Refresh(context.Background())
}
}
case <-stopMonitor:
logging.Logger().Debug("stopping background session monitor")
return
}
}
}()
// Wait for termination signal
sig := <-sigChan
logging.Logger().Info("received signal, shutting down", zap.String("signal", sig.String()))
close(stopMonitor)
}
func stop() {
pidFile := getPidFilePath()
pid := readPidFile()
if pid == 0 {
fmt.Fprintf(os.Stderr, "anvillm is not running\n")
os.Exit(1)
}
if !isProcessRunning(pid) {
fmt.Fprintf(os.Stderr, "anvillm is not running (stale PID file)\n")
os.Remove(pidFile)
os.Exit(1)
}
// Send SIGTERM
process, err := os.FindProcess(pid)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to find process: %v\n", err)
os.Exit(1)
}
if err := process.Signal(syscall.SIGTERM); err != nil {
fmt.Fprintf(os.Stderr, "Failed to send SIGTERM: %v\n", err)
os.Exit(1)
}
logging.Logger().Info("stopping anvillm", zap.Int("pid", pid))
fmt.Printf("Stopping anvillm (PID %d)\n", pid)
}
func status() {
pid := readPidFile()
if pid == 0 {
fmt.Println("anvillm is not running")
os.Exit(1)
}
if !isProcessRunning(pid) {
fmt.Println("anvillm is not running (stale PID file)")
os.Exit(1)
}
logging.Logger().Info("anvillm status check", zap.Int("pid", pid), zap.Bool("running", true))
fmt.Printf("anvillm is running (PID %d)\n", pid)
}
// readPidFile reads the PID from the PID file, returns 0 if not found
func readPidFile() int {
pidFile := getPidFilePath()
data, err := os.ReadFile(pidFile)
if err != nil {
return 0
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return 0
}
return pid
}
// isProcessRunning checks if a process with the given PID is running
func isProcessRunning(pid int) bool {
// Send signal 0 to check if process exists
process, err := os.FindProcess(pid)
if err != nil {
return false
}
err = process.Signal(syscall.Signal(0))
return err == nil
}