feat: add gateway daemon commands with auto-restart and logging
Add start, stop, restart, and status commands for running the gateway as a background daemon with automatic crash recovery. This allows the gateway to run without occupying a terminal, making it suitable for production deployments on resource-constrained hardware like Raspberry Pi. Features: - Daemon mode with PID file management - Auto-restart with exponential backoff (3 attempts, 5-min window) - File logging with automatic rotation (100MB, 3 backups) - Status tracking (PID, uptime, restart count) - Graceful shutdown with SIGTERM handling New commands: - picoclaw gateway start # Start as daemon - picoclaw gateway stop # Stop daemon - picoclaw gateway restart # Restart with auto-recovery - picoclaw gateway status # Show daemon status Architecture: - New pkg/daemon package following existing service patterns - Atomic state persistence (temp file + rename) - Thread-safe operations with mutex protection - Cross-platform support (Linux, macOS, ARM64, x86_64)
This commit is contained in:
parent
fd26fa7459
commit
f62439c8d6
9 changed files with 1869 additions and 95 deletions
95
README.md
95
README.md
|
|
@ -280,6 +280,91 @@ That's it! You have a working AI assistant in 2 minutes.
|
|||
|
||||
---
|
||||
|
||||
## 🚀 Running the Gateway
|
||||
|
||||
PicoClaw provides two modes for running the gateway:
|
||||
|
||||
### Foreground Mode (Development)
|
||||
|
||||
Run the gateway in the foreground for development and testing:
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
Press `Ctrl+C` to stop the gateway.
|
||||
|
||||
### Daemon Mode (Production)
|
||||
|
||||
Run the gateway as a background daemon for production deployments:
|
||||
|
||||
```bash
|
||||
# Start the gateway in the background
|
||||
picoclaw gateway start
|
||||
|
||||
# Check gateway status
|
||||
picoclaw gateway status
|
||||
|
||||
# Stop the gateway
|
||||
picoclaw gateway stop
|
||||
|
||||
# Restart the gateway
|
||||
picoclaw gateway restart
|
||||
```
|
||||
|
||||
**Daemon Features:**
|
||||
- **Auto-restart**: If the gateway crashes, it automatically restarts up to 3 times with exponential backoff
|
||||
- **Logging**: All logs are written to `~/.picoclaw/gateway.log` with automatic rotation (100MB per file, 3 backups)
|
||||
- **Status tracking**: View PID, uptime, and restart count with `picoclaw gateway status`
|
||||
- **No terminal needed**: The gateway runs in the background, freeing up your terminal
|
||||
|
||||
**Log File Location:**
|
||||
```
|
||||
~/.picoclaw/gateway.log # Current log
|
||||
~/.picoclaw/gateway.log.1 # First backup
|
||||
~/.picoclaw/gateway.log.2 # Second backup
|
||||
~/.picoclaw/gateway.log.3 # Third backup
|
||||
```
|
||||
|
||||
**Viewing Logs:**
|
||||
```bash
|
||||
# Follow the log file
|
||||
tail -f ~/.picoclaw/gateway.log
|
||||
|
||||
# View last 100 lines
|
||||
tail -n 100 ~/.picoclaw/gateway.log
|
||||
```
|
||||
|
||||
**systemd Integration (Linux):**
|
||||
|
||||
For automatic startup on boot, create a systemd service:
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/picoclaw.service
|
||||
[Unit]
|
||||
Description=PicoClaw Gateway
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=forking
|
||||
User=pi
|
||||
ExecStart=/home/pi/picoclaw gateway start
|
||||
ExecStop=/home/pi/picoclaw gateway stop
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
```bash
|
||||
sudo systemctl enable picoclaw
|
||||
sudo systemctl start picoclaw
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💬 Chat Apps
|
||||
|
||||
Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
|
||||
|
|
@ -1108,12 +1193,16 @@ picoclaw agent -m "Hello"
|
|||
## CLI Reference
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------- | ----------------------------- |
|
||||
| ------------------------------- | ------------------------------------------ |
|
||||
| `picoclaw onboard` | Initialize config & workspace |
|
||||
| `picoclaw agent -m "..."` | Chat with the agent |
|
||||
| `picoclaw agent` | Interactive chat mode |
|
||||
| `picoclaw gateway` | Start the gateway |
|
||||
| `picoclaw status` | Show status |
|
||||
| `picoclaw gateway` | Start gateway in foreground |
|
||||
| `picoclaw gateway start` | Start gateway as daemon (background) |
|
||||
| `picoclaw gateway stop` | Stop the gateway daemon |
|
||||
| `picoclaw gateway restart` | Restart the gateway daemon |
|
||||
| `picoclaw gateway status` | Show gateway daemon status (PID, uptime) |
|
||||
| `picoclaw status` | Show picoclaw status |
|
||||
| `picoclaw cron list` | List all scheduled jobs |
|
||||
| `picoclaw cron add ...` | Add a scheduled job |
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/agent"
|
||||
|
|
@ -28,27 +29,35 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/voice"
|
||||
)
|
||||
|
||||
func gatewayCmd() {
|
||||
// Check for --debug flag
|
||||
args := os.Args[2:]
|
||||
for _, arg := range args {
|
||||
if arg == "--debug" || arg == "-d" {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
break
|
||||
}
|
||||
}
|
||||
// gatewayRunner holds the initialized gateway components.
|
||||
// This allows the gateway lifecycle to be managed externally (e.g., by daemon service).
|
||||
type gatewayRunner struct {
|
||||
cfg *config.Config
|
||||
provider providers.LLMProvider
|
||||
msgBus *bus.MessageBus
|
||||
agentLoop *agent.AgentLoop
|
||||
cronService *cron.CronService
|
||||
heartbeatService *heartbeat.HeartbeatService
|
||||
channelManager *channels.Manager
|
||||
deviceService *devices.Service
|
||||
healthServer *health.Server
|
||||
stateManager *state.Manager
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// createGatewayRunner initializes all gateway components and returns a runner.
|
||||
// This function does NOT start any services - it only initializes them.
|
||||
// The caller is responsible for calling the returned start function.
|
||||
func createGatewayRunner(isDaemon bool) (*gatewayRunner, error) {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
return nil, fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
|
||||
provider, modelID, err := providers.CreateProvider(cfg)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating provider: %v\n", err)
|
||||
os.Exit(1)
|
||||
return nil, fmt.Errorf("error creating provider: %w", err)
|
||||
}
|
||||
// Use the resolved model ID from provider creation
|
||||
if modelID != "" {
|
||||
|
|
@ -58,24 +67,6 @@ func gatewayCmd() {
|
|||
msgBus := bus.NewMessageBus()
|
||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Print agent startup info
|
||||
fmt.Println("\n📦 Agent Status:")
|
||||
startupInfo := agentLoop.GetStartupInfo()
|
||||
toolsInfo := startupInfo["tools"].(map[string]any)
|
||||
skillsInfo := startupInfo["skills"].(map[string]any)
|
||||
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
|
||||
fmt.Printf(" • Skills: %d/%d available\n",
|
||||
skillsInfo["available"],
|
||||
skillsInfo["total"])
|
||||
|
||||
// Log to file as well
|
||||
logger.InfoCF("agent", "Agent initialized",
|
||||
map[string]any{
|
||||
"tools_count": toolsInfo["count"],
|
||||
"skills_total": skillsInfo["total"],
|
||||
"skills_available": skillsInfo["available"],
|
||||
})
|
||||
|
||||
// Setup cron tool and service
|
||||
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
||||
cronService := setupCronTool(
|
||||
|
|
@ -114,8 +105,7 @@ func gatewayCmd() {
|
|||
|
||||
channelManager, err := channels.NewManager(cfg, msgBus)
|
||||
if err != nil {
|
||||
fmt.Printf("Error creating channel manager: %v\n", err)
|
||||
os.Exit(1)
|
||||
return nil, fmt.Errorf("error creating channel manager: %w", err)
|
||||
}
|
||||
|
||||
// Inject channel manager into agent loop for command handling
|
||||
|
|
@ -157,28 +147,7 @@ func gatewayCmd() {
|
|||
}
|
||||
}
|
||||
|
||||
enabledChannels := channelManager.GetEnabledChannels()
|
||||
if len(enabledChannels) > 0 {
|
||||
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
||||
} else {
|
||||
fmt.Println("⚠ Warning: No channels enabled")
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
fmt.Println("Press Ctrl+C to stop")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := cronService.Start(); err != nil {
|
||||
fmt.Printf("Error starting cron service: %v\n", err)
|
||||
}
|
||||
fmt.Println("✓ Cron service started")
|
||||
|
||||
if err := heartbeatService.Start(); err != nil {
|
||||
fmt.Printf("Error starting heartbeat service: %v\n", err)
|
||||
}
|
||||
fmt.Println("✓ Heartbeat service started")
|
||||
|
||||
stateManager := state.NewManager(cfg.WorkspacePath())
|
||||
deviceService := devices.NewService(devices.Config{
|
||||
|
|
@ -186,42 +155,183 @@ func gatewayCmd() {
|
|||
MonitorUSB: cfg.Devices.MonitorUSB,
|
||||
}, stateManager)
|
||||
deviceService.SetBus(msgBus)
|
||||
if err := deviceService.Start(ctx); err != nil {
|
||||
fmt.Printf("Error starting device service: %v\n", err)
|
||||
} else if cfg.Devices.Enabled {
|
||||
|
||||
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
|
||||
return &gatewayRunner{
|
||||
cfg: cfg,
|
||||
provider: provider,
|
||||
msgBus: msgBus,
|
||||
agentLoop: agentLoop,
|
||||
cronService: cronService,
|
||||
heartbeatService: heartbeatService,
|
||||
channelManager: channelManager,
|
||||
deviceService: deviceService,
|
||||
healthServer: healthServer,
|
||||
stateManager: stateManager,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// run starts all gateway services and waits for context cancellation.
|
||||
// This method blocks until the gateway is stopped.
|
||||
func (r *gatewayRunner) run(isDaemon bool) error {
|
||||
// Print startup info only in foreground mode
|
||||
if !isDaemon {
|
||||
// Print agent startup info
|
||||
fmt.Println("\n📦 Agent Status:")
|
||||
startupInfo := r.agentLoop.GetStartupInfo()
|
||||
toolsInfo := startupInfo["tools"].(map[string]any)
|
||||
skillsInfo := startupInfo["skills"].(map[string]any)
|
||||
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
|
||||
fmt.Printf(" • Skills: %d/%d available\n",
|
||||
skillsInfo["available"],
|
||||
skillsInfo["total"])
|
||||
|
||||
// Log to file as well
|
||||
logger.InfoCF("agent", "Agent initialized",
|
||||
map[string]any{
|
||||
"tools_count": toolsInfo["count"],
|
||||
"skills_total": skillsInfo["total"],
|
||||
"skills_available": skillsInfo["available"],
|
||||
})
|
||||
|
||||
enabledChannels := r.channelManager.GetEnabledChannels()
|
||||
if len(enabledChannels) > 0 {
|
||||
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
||||
} else {
|
||||
fmt.Println("⚠ Warning: No channels enabled")
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Gateway started on %s:%d\n", r.cfg.Gateway.Host, r.cfg.Gateway.Port)
|
||||
fmt.Println("Press Ctrl+C to stop")
|
||||
}
|
||||
|
||||
// Start cron service
|
||||
if err := r.cronService.Start(); err != nil {
|
||||
return fmt.Errorf("error starting cron service: %w", err)
|
||||
}
|
||||
if !isDaemon {
|
||||
fmt.Println("✓ Cron service started")
|
||||
}
|
||||
|
||||
// Start heartbeat service
|
||||
if err := r.heartbeatService.Start(); err != nil {
|
||||
return fmt.Errorf("error starting heartbeat service: %w", err)
|
||||
}
|
||||
if !isDaemon {
|
||||
fmt.Println("✓ Heartbeat service started")
|
||||
}
|
||||
|
||||
// Start device service
|
||||
if err := r.deviceService.Start(r.ctx); err != nil {
|
||||
logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()})
|
||||
} else if r.cfg.Devices.Enabled && !isDaemon {
|
||||
fmt.Println("✓ Device event service started")
|
||||
}
|
||||
|
||||
if err := channelManager.StartAll(ctx); err != nil {
|
||||
fmt.Printf("Error starting channels: %v\n", err)
|
||||
// Start channels
|
||||
if err := r.channelManager.StartAll(r.ctx); err != nil {
|
||||
return fmt.Errorf("error starting channels: %w", err)
|
||||
}
|
||||
|
||||
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
// Start health server
|
||||
go func() {
|
||||
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
|
||||
if err := r.healthServer.Start(); err != nil && err != http.ErrServerClosed {
|
||||
logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
|
||||
}
|
||||
}()
|
||||
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||
if !isDaemon {
|
||||
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", r.cfg.Gateway.Host, r.cfg.Gateway.Port)
|
||||
}
|
||||
|
||||
go agentLoop.Run(ctx)
|
||||
// Start agent loop
|
||||
go r.agentLoop.Run(r.ctx)
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt)
|
||||
<-sigChan
|
||||
// Wait for context cancellation
|
||||
<-r.ctx.Done()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stop gracefully stops all gateway services.
|
||||
func (r *gatewayRunner) stop() {
|
||||
logger.InfoC("gateway", "Shutting down...")
|
||||
|
||||
if !isDaemonMode() {
|
||||
fmt.Println("\nShutting down...")
|
||||
if cp, ok := provider.(providers.StatefulProvider); ok {
|
||||
}
|
||||
|
||||
if cp, ok := r.provider.(providers.StatefulProvider); ok {
|
||||
cp.Close()
|
||||
}
|
||||
cancel()
|
||||
healthServer.Stop(context.Background())
|
||||
deviceService.Stop()
|
||||
heartbeatService.Stop()
|
||||
cronService.Stop()
|
||||
agentLoop.Stop()
|
||||
channelManager.StopAll(ctx)
|
||||
|
||||
r.cancel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r.healthServer.Stop(ctx)
|
||||
r.deviceService.Stop()
|
||||
r.heartbeatService.Stop()
|
||||
r.cronService.Stop()
|
||||
r.agentLoop.Stop()
|
||||
r.channelManager.StopAll(ctx)
|
||||
|
||||
if !isDaemonMode() {
|
||||
fmt.Println("✓ Gateway stopped")
|
||||
}
|
||||
|
||||
logger.InfoC("gateway", "Shutdown complete")
|
||||
}
|
||||
|
||||
// isDaemonMode returns true if the process is running in daemon mode.
|
||||
func isDaemonMode() bool {
|
||||
return os.Getenv("PICOCLAW_DAEMON") == "1"
|
||||
}
|
||||
|
||||
// gatewayCmd runs the gateway in the foreground.
|
||||
func gatewayCmd() {
|
||||
// Check for --debug flag
|
||||
args := os.Args[2:]
|
||||
for _, arg := range args {
|
||||
if arg == "--debug" || arg == "-d" {
|
||||
logger.SetLevel(logger.DEBUG)
|
||||
if !isDaemonMode() {
|
||||
fmt.Println("🔍 Debug mode enabled")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Create gateway runner
|
||||
runner, err := createGatewayRunner(isDaemonMode())
|
||||
if err != nil {
|
||||
if isDaemonMode() {
|
||||
logger.ErrorCF("gateway", "Failed to initialize gateway", map[string]any{"error": err.Error()})
|
||||
} else {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Set up signal handling for graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// Start the gateway
|
||||
go func() {
|
||||
if err := runner.run(isDaemonMode()); err != nil {
|
||||
logger.ErrorCF("gateway", "Gateway error", map[string]any{"error": err.Error()})
|
||||
runner.stop()
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal
|
||||
<-sigChan
|
||||
runner.stop()
|
||||
}
|
||||
|
||||
func setupCronTool(
|
||||
|
|
|
|||
197
cmd/picoclaw/cmd_service.go
Normal file
197
cmd/picoclaw/cmd_service.go
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/daemon"
|
||||
)
|
||||
|
||||
// gatewayServiceCmd handles gateway service management subcommands.
|
||||
// Usage: picoclaw gateway <start|stop|restart|status>
|
||||
func gatewayServiceCmd(subcommand string) {
|
||||
// Get the picoclaw config directory
|
||||
configDir := getConfigDir()
|
||||
|
||||
// Get the path to the current binary
|
||||
binaryPath, err := os.Executable()
|
||||
if err != nil {
|
||||
fmt.Printf("Error: Unable to determine binary path: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Create daemon service configuration
|
||||
serviceConfig := &daemon.ServiceConfig{
|
||||
ConfigDir: configDir,
|
||||
BinaryPath: binaryPath,
|
||||
Version: formatVersion(),
|
||||
// Use default restart policy: 3 attempts, exponential backoff
|
||||
RestartPolicy: daemon.DefaultRestartPolicy(),
|
||||
// Log files will be stored in config directory
|
||||
}
|
||||
|
||||
// Create the daemon service
|
||||
service, err := daemon.NewService(serviceConfig)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: Failed to create daemon service: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Execute the requested subcommand
|
||||
switch subcommand {
|
||||
case "start":
|
||||
handleStart(service)
|
||||
|
||||
case "stop":
|
||||
handleStop(service)
|
||||
|
||||
case "restart":
|
||||
handleRestart(service)
|
||||
|
||||
case "status":
|
||||
handleStatus(service)
|
||||
|
||||
default:
|
||||
fmt.Printf("Error: Unknown gateway subcommand: %s\n", subcommand)
|
||||
fmt.Println("Valid subcommands are: start, stop, restart, status")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStart starts the gateway daemon.
|
||||
func handleStart(service *daemon.Service) {
|
||||
fmt.Printf("%s Starting PicoClaw Gateway...\n", logo)
|
||||
|
||||
if err := service.Start(); err != nil {
|
||||
switch e := err.(type) {
|
||||
case *daemon.AlreadyRunningError:
|
||||
fmt.Printf("✗ Gateway is already running with PID %d\n", e.GetPID())
|
||||
fmt.Println("\nUse 'picoclaw gateway status' for more information")
|
||||
default:
|
||||
fmt.Printf("✗ Failed to start gateway: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("✓ Gateway started successfully")
|
||||
fmt.Printf(" PID: %d\n", service.Status().PID)
|
||||
fmt.Printf(" Log: %s\n", filepath.Join(getConfigDir(), daemon.DefaultLogFileName))
|
||||
fmt.Println("\nUse 'picoclaw gateway status' to check the gateway status")
|
||||
fmt.Println("Use 'picoclaw gateway stop' to stop the gateway")
|
||||
}
|
||||
|
||||
// handleStop stops the gateway daemon.
|
||||
func handleStop(service *daemon.Service) {
|
||||
fmt.Printf("%s Stopping PicoClaw Gateway...\n", logo)
|
||||
|
||||
if err := service.Stop(); err != nil {
|
||||
switch err.(type) {
|
||||
case *daemon.NotRunningError:
|
||||
fmt.Println("✗ Gateway is not running")
|
||||
fmt.Println("\nUse 'picoclaw gateway start' to start the gateway")
|
||||
default:
|
||||
fmt.Printf("✗ Failed to stop gateway: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("✓ Gateway stopped successfully")
|
||||
}
|
||||
|
||||
// handleRestart restarts the gateway daemon with automatic crash recovery.
|
||||
func handleRestart(service *daemon.Service) {
|
||||
fmt.Printf("%s Restarting PicoClaw Gateway...\n", logo)
|
||||
|
||||
if err := service.Restart(); err != nil {
|
||||
switch e := err.(type) {
|
||||
case *daemon.AlreadyRunningError:
|
||||
fmt.Printf("✗ Gateway is already running with PID %d\n", e.GetPID())
|
||||
fmt.Println("\nUse 'picoclaw gateway stop' first, then 'picoclaw gateway start'")
|
||||
case *daemon.MaxRestartsExceededError:
|
||||
fmt.Printf("✗ Maximum restart attempts exceeded (%d attempts in %v)\n",
|
||||
e.GetAttempts(), e.Window)
|
||||
fmt.Println("\nThe gateway is crashing repeatedly. Check the log file for errors:")
|
||||
fmt.Printf(" Log: %s\n", filepath.Join(getConfigDir(), daemon.DefaultLogFileName))
|
||||
default:
|
||||
fmt.Printf("✗ Failed to restart gateway: %v\n", err)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
status := service.Status()
|
||||
fmt.Println("✓ Gateway restarted successfully")
|
||||
fmt.Printf(" PID: %d\n", status.PID)
|
||||
fmt.Printf(" Restart count: %d\n", status.RestartCount)
|
||||
fmt.Printf(" Log: %s\n", filepath.Join(getConfigDir(), daemon.DefaultLogFileName))
|
||||
}
|
||||
|
||||
// handleStatus displays the current status of the gateway daemon.
|
||||
func handleStatus(service *daemon.Service) {
|
||||
status := service.Status()
|
||||
|
||||
fmt.Printf("%s PicoClaw Gateway Status\n", logo)
|
||||
fmt.Printf("Version: %s\n\n", formatVersion())
|
||||
|
||||
if !status.IsRunning {
|
||||
fmt.Println("Status: Stopped")
|
||||
fmt.Println("\nUse 'picoclaw gateway start' to start the gateway")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Status: Running")
|
||||
fmt.Printf(" PID: %d\n", status.PID)
|
||||
|
||||
if !status.StartTime.IsZero() {
|
||||
fmt.Printf(" Started: %s\n", status.StartTime.Format("2006-01-02 15:04:05"))
|
||||
|
||||
if status.Uptime > 0 {
|
||||
fmt.Printf(" Uptime: %s\n", formatUptime(status.Uptime))
|
||||
}
|
||||
}
|
||||
|
||||
if status.RestartCount > 0 {
|
||||
fmt.Printf(" Restarts: %d\n", status.RestartCount)
|
||||
}
|
||||
|
||||
if status.Version != "" {
|
||||
fmt.Printf(" Version: %s\n", status.Version)
|
||||
}
|
||||
|
||||
// Log file location
|
||||
logPath := filepath.Join(getConfigDir(), daemon.DefaultLogFileName)
|
||||
fmt.Printf("\nLog file: %s\n", logPath)
|
||||
}
|
||||
|
||||
// getConfigDir returns the picoclaw configuration directory.
|
||||
func getConfigDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ".picoclaw"
|
||||
}
|
||||
return filepath.Join(home, ".picoclaw")
|
||||
}
|
||||
|
||||
// formatUptime formats a duration in a human-readable way.
|
||||
func formatUptime(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return d.String()
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
hours := int(d.Hours())
|
||||
minutes := int(d.Minutes()) % 60
|
||||
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||
}
|
||||
days := int(d.Hours() / 24)
|
||||
hours := int(d.Hours()) % 24
|
||||
return fmt.Sprintf("%dd %dh", days, hours)
|
||||
}
|
||||
|
|
@ -106,6 +106,16 @@ func main() {
|
|||
case "agent":
|
||||
agentCmd()
|
||||
case "gateway":
|
||||
// Check for subcommands: start, stop, restart, status
|
||||
if len(os.Args) >= 3 {
|
||||
subcommand := os.Args[2]
|
||||
switch subcommand {
|
||||
case "start", "stop", "restart", "status":
|
||||
gatewayServiceCmd(subcommand)
|
||||
return
|
||||
}
|
||||
}
|
||||
// No subcommand or unrecognized subcommand - run gateway in foreground
|
||||
gatewayCmd()
|
||||
case "status":
|
||||
statusCmd()
|
||||
|
|
@ -181,7 +191,11 @@ func printHelp() {
|
|||
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
|
||||
fmt.Println(" agent Interact with the agent directly")
|
||||
fmt.Println(" auth Manage authentication (login, logout, status)")
|
||||
fmt.Println(" gateway Start picoclaw gateway")
|
||||
fmt.Println(" gateway Start picoclaw gateway (foreground mode)")
|
||||
fmt.Println(" gateway start Start gateway as daemon (background)")
|
||||
fmt.Println(" gateway stop Stop the gateway daemon")
|
||||
fmt.Println(" gateway restart Restart the gateway daemon")
|
||||
fmt.Println(" gateway status Show gateway daemon status")
|
||||
fmt.Println(" status Show picoclaw status")
|
||||
fmt.Println(" cron Manage scheduled tasks")
|
||||
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
|
||||
|
|
|
|||
243
pkg/daemon/logger.go
Normal file
243
pkg/daemon/logger.go
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogConfig defines the configuration for daemon logging.
|
||||
type LogConfig struct {
|
||||
// Path is the log file path
|
||||
Path string
|
||||
|
||||
// MaxSize is the maximum size in bytes before rotation
|
||||
MaxSize int64
|
||||
|
||||
// MaxBackups is the maximum number of backup files to keep
|
||||
MaxBackups int
|
||||
|
||||
// MaxAge is the maximum age to keep a log file before deletion
|
||||
MaxAge time.Duration
|
||||
}
|
||||
|
||||
// DefaultLogConfig returns a log configuration with sensible defaults.
|
||||
func DefaultLogConfig(path string) *LogConfig {
|
||||
return &LogConfig{
|
||||
Path: path,
|
||||
MaxSize: 100 * 1024 * 1024, // 100 MB
|
||||
MaxBackups: 3, // Keep 3 backups
|
||||
MaxAge: 30 * 24 * time.Hour, // 30 days
|
||||
}
|
||||
}
|
||||
|
||||
// Logger manages daemon logging with automatic file rotation.
|
||||
//
|
||||
// Design rationale:
|
||||
// - Long-running daemons need log rotation to prevent disk space exhaustion
|
||||
// - Atomic rotation prevents log loss during rotation
|
||||
// - Size-based rotation is more predictable than time-based rotation
|
||||
// - Automatic cleanup of old logs prevents accumulation
|
||||
type Logger struct {
|
||||
config *LogConfig
|
||||
file *os.File
|
||||
size int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewLogger creates a new daemon logger with the given configuration.
|
||||
func NewLogger(config *LogConfig) (*Logger, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("log config cannot be nil")
|
||||
}
|
||||
|
||||
l := &Logger{
|
||||
config: config,
|
||||
}
|
||||
|
||||
// Ensure log directory exists
|
||||
dir := filepath.Dir(config.Path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create log directory: %w", err)
|
||||
}
|
||||
|
||||
// Open log file
|
||||
if err := l.openLogFile(); err != nil {
|
||||
return nil, fmt.Errorf("failed to open log file: %w", err)
|
||||
}
|
||||
|
||||
// Get current file size
|
||||
info, err := l.file.Stat()
|
||||
if err != nil {
|
||||
l.file.Close()
|
||||
return nil, fmt.Errorf("failed to stat log file: %w", err)
|
||||
}
|
||||
l.size = info.Size()
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// openLogFile opens the log file for appending.
|
||||
// Must be called with the lock held.
|
||||
func (l *Logger) openLogFile() error {
|
||||
var err error
|
||||
l.file, err = os.OpenFile(l.config.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
return err
|
||||
}
|
||||
|
||||
// Write writes a message to the log file.
|
||||
// If the file size exceeds MaxSize, it rotates the log file first.
|
||||
func (l *Logger) Write(message string) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
// Check if rotation is needed
|
||||
if l.size+l.fileSizeFor(message) > l.config.MaxSize {
|
||||
if err := l.rotate(); err != nil {
|
||||
// Log rotation failed, try writing to current file anyway
|
||||
// but include error message
|
||||
message = fmt.Sprintf("[ERROR] Failed to rotate log: %v\n%s", err, message)
|
||||
}
|
||||
}
|
||||
|
||||
// Write message
|
||||
n, err := l.file.WriteString(message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write to log file: %w", err)
|
||||
}
|
||||
|
||||
l.size += int64(n)
|
||||
|
||||
// Sync to ensure data is written to disk
|
||||
if err := l.file.Sync(); err != nil {
|
||||
return fmt.Errorf("failed to sync log file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileSizeFor estimates the size of the message in bytes.
|
||||
func (l *Logger) fileSizeFor(message string) int64 {
|
||||
return int64(len(message))
|
||||
}
|
||||
|
||||
// rotate performs log file rotation.
|
||||
// Must be called with the lock held.
|
||||
func (l *Logger) rotate() error {
|
||||
// Close current log file
|
||||
if l.file != nil {
|
||||
if err := l.file.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close current log file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Find the next available backup number
|
||||
backupNum := 1
|
||||
for {
|
||||
backupPath := l.backupPath(backupNum)
|
||||
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
backupNum++
|
||||
}
|
||||
|
||||
// Rotate existing backups
|
||||
for i := backupNum - 1; i >= 1; i-- {
|
||||
oldPath := l.backupPath(i)
|
||||
newPath := l.backupPath(i + 1)
|
||||
|
||||
if i >= l.config.MaxBackups {
|
||||
// Delete old backup if we have too many
|
||||
os.Remove(oldPath)
|
||||
} else {
|
||||
// Rename backup
|
||||
os.Rename(oldPath, newPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Move current log to backup
|
||||
if err := os.Rename(l.config.Path, l.backupPath(1)); err != nil {
|
||||
// Reopen current file if rename failed
|
||||
l.openLogFile()
|
||||
return fmt.Errorf("failed to rotate log file: %w", err)
|
||||
}
|
||||
|
||||
// Clean up old log files based on age
|
||||
l.cleanupOldLogs()
|
||||
|
||||
// Open new log file
|
||||
if err := l.openLogFile(); err != nil {
|
||||
return fmt.Errorf("failed to open new log file after rotation: %w", err)
|
||||
}
|
||||
|
||||
// Reset size
|
||||
l.size = 0
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// backupPath returns the path for a backup file with the given number.
|
||||
func (l *Logger) backupPath(num int) string {
|
||||
return fmt.Sprintf("%s.%d", l.config.Path, num)
|
||||
}
|
||||
|
||||
// cleanupOldLogs removes log files older than MaxAge.
|
||||
// Must be called with the lock held.
|
||||
func (l *Logger) cleanupOldLogs() {
|
||||
if l.config.MaxAge <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-l.config.MaxAge)
|
||||
|
||||
// Check all backup files
|
||||
for i := 1; i <= l.config.MaxBackups; i++ {
|
||||
backupPath := l.backupPath(i)
|
||||
|
||||
info, err := os.Stat(backupPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Delete if older than cutoff
|
||||
if info.ModTime().Before(cutoff) {
|
||||
os.Remove(backupPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the log file.
|
||||
func (l *Logger) Close() error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.file != nil {
|
||||
return l.file.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rotate forces an immediate log rotation.
|
||||
// Useful for testing or manual log management.
|
||||
func (l *Logger) Rotate() error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
return l.rotate()
|
||||
}
|
||||
|
||||
// GetSize returns the current size of the log file in bytes.
|
||||
func (l *Logger) GetSize() int64 {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
return l.size
|
||||
}
|
||||
230
pkg/daemon/pidfile.go
Normal file
230
pkg/daemon/pidfile.go
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PIDFile manages process ID file with atomic operations.
|
||||
// It provides thread-safe operations for creating, reading, and removing PID files.
|
||||
//
|
||||
// Design rationale:
|
||||
// - PID files are the canonical Unix way to track daemon processes
|
||||
// - Atomic operations prevent race conditions during concurrent access
|
||||
// - Proper cleanup prevents stale PID files from accumulating
|
||||
type PIDFile struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewPIDFile creates a new PID file manager for the given path.
|
||||
func NewPIDFile(path string) *PIDFile {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(path)
|
||||
os.MkdirAll(dir, 0o755)
|
||||
|
||||
return &PIDFile{
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
|
||||
// Write atomically writes the current process ID to the PID file.
|
||||
// Returns an error if a PID file already exists with a running process.
|
||||
//
|
||||
// This prevents multiple instances of the gateway from running simultaneously,
|
||||
// which could cause resource conflicts and undefined behavior.
|
||||
func (p *PIDFile) Write() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Check for existing PID file
|
||||
if _, err := os.Stat(p.path); err == nil {
|
||||
// PID file exists, check if process is running
|
||||
pid, err := p.read()
|
||||
if err == nil && p.isProcessRunning(pid) {
|
||||
return &ProcessRunningError{
|
||||
pid: pid,
|
||||
Path: p.path,
|
||||
}
|
||||
}
|
||||
// Process is not running, stale PID file, continue
|
||||
}
|
||||
|
||||
// Write PID to temp file in same directory (atomic preparation)
|
||||
pid := os.Getpid()
|
||||
pidStr := strconv.Itoa(pid)
|
||||
|
||||
tempFile := p.path + ".tmp"
|
||||
if err := os.WriteFile(tempFile, []byte(pidStr), 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write temp PID file: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename from temp to target
|
||||
if err := os.Rename(tempFile, p.path); err != nil {
|
||||
os.Remove(tempFile) // Cleanup temp file
|
||||
return fmt.Errorf("failed to atomically create PID file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove deletes the PID file.
|
||||
// Safe to call multiple times (idempotent operation).
|
||||
func (p *PIDFile) Remove() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
_ = os.Remove(p.path)
|
||||
}
|
||||
|
||||
// Read returns the process ID stored in the PID file.
|
||||
// Returns 0 if the file doesn't exist or cannot be read.
|
||||
func (p *PIDFile) Read() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
pid, err := p.read()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return pid
|
||||
}
|
||||
|
||||
// read must be called with the lock held.
|
||||
func (p *PIDFile) read() (int, error) {
|
||||
data, err := os.ReadFile(p.path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(string(data))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid PID in file: %w", err)
|
||||
}
|
||||
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
// IsProcessRunning checks if the process with the given PID is still running.
|
||||
func (p *PIDFile) IsProcessRunning() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
pid, err := p.read()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return p.isProcessRunning(pid)
|
||||
}
|
||||
|
||||
// isProcessRunning must be called with the lock held.
|
||||
func (p *PIDFile) isProcessRunning(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Send signal 0 to check if process exists
|
||||
// On Unix, this doesn't actually send a signal but checks if the process exists
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to send signal 0 (no signal, just check existence)
|
||||
err = process.Signal(syscall.Signal(0))
|
||||
if err != nil {
|
||||
// Process doesn't exist or we don't have permission to signal it
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GetUptime returns the uptime of the process if it's running.
|
||||
// Returns zero duration if the process is not running or uptime cannot be determined.
|
||||
func (p *PIDFile) GetUptime() time.Duration {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
pid, err := p.read()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return p.getProcessUptime(pid)
|
||||
}
|
||||
|
||||
// getProcessUptime must be called with the lock held.
|
||||
func (p *PIDFile) getProcessUptime(pid int) time.Duration {
|
||||
if pid <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Read /proc/<pid>/stat to get process start time
|
||||
// Format: pid (comm) state ppid pgrp session tty_nr tpgid flags minflt cminflt ...
|
||||
// The 22nd field (index 21) is the start time in jiffies
|
||||
statPath := fmt.Sprintf("/proc/%d/stat", pid)
|
||||
data, err := os.ReadFile(statPath)
|
||||
if err != nil {
|
||||
// Process doesn't exist or /proc not available
|
||||
return 0
|
||||
}
|
||||
|
||||
// Parse the stat file
|
||||
// Find the last ')' to handle command names with spaces
|
||||
fields := string(data)
|
||||
lastParen := -1
|
||||
for i, c := range fields {
|
||||
if c == ')' {
|
||||
lastParen = i
|
||||
}
|
||||
}
|
||||
|
||||
if lastParen == -1 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Extract fields after the command name
|
||||
rest := fields[lastParen+2:] // Skip ") "
|
||||
var startTimeJiffies uint64
|
||||
_, err = fmt.Sscanf(rest, "%*c %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %*d %d",
|
||||
&startTimeJiffies)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Convert jiffies to nanoseconds
|
||||
// Assumes USER_HZ=100 (common on Linux)
|
||||
// This is a simplification; for production code, use sysconf(_SC_CLK_TCK)
|
||||
clockTick := int64(100)
|
||||
startTime := time.Unix(int64(startTimeJiffies)/clockTick, 0)
|
||||
|
||||
return time.Since(startTime)
|
||||
}
|
||||
|
||||
// ProcessRunningError is returned when attempting to write a PID file
|
||||
// but a process is already running.
|
||||
type ProcessRunningError struct {
|
||||
pid int
|
||||
Path string
|
||||
}
|
||||
|
||||
func (e *ProcessRunningError) Error() string {
|
||||
return fmt.Sprintf("process already running with PID %d (PID file: %s)", e.pid, e.Path)
|
||||
}
|
||||
|
||||
// GetPID returns the PID of the running process.
|
||||
func (e *ProcessRunningError) GetPID() int {
|
||||
return e.pid
|
||||
}
|
||||
190
pkg/daemon/restart.go
Normal file
190
pkg/daemon/restart.go
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RestartPolicy defines the strategy for process restart after crashes.
|
||||
//
|
||||
// Design rationale:
|
||||
// - Automatic restart improves reliability for long-running services
|
||||
// - Exponential backoff prevents rapid restart loops that could indicate systemic issues
|
||||
// - Maximum attempt limits prevent infinite restart cycles
|
||||
// - Time window ensures crashes are counted within a meaningful period
|
||||
type RestartPolicy struct {
|
||||
// MaxAttempts is the maximum number of restart attempts before giving up.
|
||||
MaxAttempts int
|
||||
|
||||
// WindowDuration is the time window in which restart attempts are counted.
|
||||
// Crashes outside this window don't count toward MaxAttempts.
|
||||
WindowDuration time.Duration
|
||||
|
||||
// BackoffBase is the initial backoff duration before first restart.
|
||||
BackoffBase time.Duration
|
||||
|
||||
// BackoffMultiplier is the factor by which backoff increases after each attempt.
|
||||
// A value of 2.0 means each backoff is double the previous one.
|
||||
BackoffMultiplier float64
|
||||
|
||||
// BackoffMax is the maximum backoff duration.
|
||||
BackoffMax time.Duration
|
||||
}
|
||||
|
||||
// DefaultRestartPolicy returns a restart policy with sensible defaults.
|
||||
//
|
||||
// Defaults:
|
||||
// - Max 3 attempts within 5 minutes
|
||||
// - Exponential backoff starting at 1 second, capped at 30 seconds
|
||||
func DefaultRestartPolicy() *RestartPolicy {
|
||||
return &RestartPolicy{
|
||||
MaxAttempts: 3,
|
||||
WindowDuration: 5 * time.Minute,
|
||||
BackoffBase: 1 * time.Second,
|
||||
BackoffMultiplier: 2.0,
|
||||
BackoffMax: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// RestartTracker tracks restart attempts and determines when restart should occur.
|
||||
type RestartTracker struct {
|
||||
policy *RestartPolicy
|
||||
attempts []time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewRestartTracker creates a new restart tracker with the given policy.
|
||||
func NewRestartTracker(policy *RestartPolicy) *RestartTracker {
|
||||
if policy == nil {
|
||||
policy = DefaultRestartPolicy()
|
||||
}
|
||||
|
||||
return &RestartTracker{
|
||||
policy: policy,
|
||||
attempts: make([]time.Time, 0, policy.MaxAttempts),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordAttempt records a restart attempt at the current time.
|
||||
// Returns the duration to wait before the next restart, or an error if
|
||||
// the maximum number of attempts has been exceeded.
|
||||
func (rt *RestartTracker) RecordAttempt() (time.Duration, error) {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Remove attempts outside the time window
|
||||
rt.cleanupOldAttempts(now)
|
||||
|
||||
// Check if we've exceeded max attempts
|
||||
if len(rt.attempts) >= rt.policy.MaxAttempts {
|
||||
return 0, &MaxRestartsExceededError{
|
||||
attempts: len(rt.attempts),
|
||||
maxAttempts: rt.policy.MaxAttempts,
|
||||
Window: rt.policy.WindowDuration,
|
||||
LastAttemptAt: rt.attempts[len(rt.attempts)-1],
|
||||
}
|
||||
}
|
||||
|
||||
// Record this attempt
|
||||
rt.attempts = append(rt.attempts, now)
|
||||
|
||||
// Calculate backoff duration
|
||||
backoff := rt.calculateBackoff(len(rt.attempts))
|
||||
|
||||
return backoff, nil
|
||||
}
|
||||
|
||||
// cleanupOldAttempts removes attempts that are outside the time window.
|
||||
// Must be called with the lock held.
|
||||
func (rt *RestartTracker) cleanupOldAttempts(now time.Time) {
|
||||
cutoff := now.Add(-rt.policy.WindowDuration)
|
||||
|
||||
// Find the first attempt within the window
|
||||
firstValid := 0
|
||||
for i, attempt := range rt.attempts {
|
||||
if attempt.After(cutoff) {
|
||||
firstValid = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old attempts
|
||||
if firstValid > 0 {
|
||||
rt.attempts = rt.attempts[firstValid:]
|
||||
}
|
||||
}
|
||||
|
||||
// calculateBackoff computes the backoff duration using exponential backoff.
|
||||
// Must be called with the lock held.
|
||||
func (rt *RestartTracker) calculateBackoff(attemptNum int) time.Duration {
|
||||
// Exponential backoff: base * multiplier^(attemptNum-1)
|
||||
backoff := rt.policy.BackoffBase
|
||||
|
||||
for i := 1; i < attemptNum; i++ {
|
||||
backoff = time.Duration(float64(backoff) * rt.policy.BackoffMultiplier)
|
||||
if backoff > rt.policy.BackoffMax {
|
||||
backoff = rt.policy.BackoffMax
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return backoff
|
||||
}
|
||||
|
||||
// Reset clears all recorded attempts.
|
||||
// Use this when the process has been running successfully for a while
|
||||
// and you want to reset the crash counter.
|
||||
func (rt *RestartTracker) Reset() {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
|
||||
rt.attempts = make([]time.Time, 0, rt.policy.MaxAttempts)
|
||||
}
|
||||
|
||||
// ShouldRestart returns true if another restart attempt should be made.
|
||||
func (rt *RestartTracker) ShouldRestart() bool {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
|
||||
rt.cleanupOldAttempts(time.Now())
|
||||
return len(rt.attempts) < rt.policy.MaxAttempts
|
||||
}
|
||||
|
||||
// GetAttemptCount returns the number of restart attempts within the current window.
|
||||
func (rt *RestartTracker) GetAttemptCount() int {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
|
||||
rt.cleanupOldAttempts(time.Now())
|
||||
return len(rt.attempts)
|
||||
}
|
||||
|
||||
// MaxRestartsExceededError is returned when the maximum number of
|
||||
// restart attempts has been exceeded within the time window.
|
||||
type MaxRestartsExceededError struct {
|
||||
attempts int
|
||||
maxAttempts int
|
||||
Window time.Duration
|
||||
LastAttemptAt time.Time
|
||||
}
|
||||
|
||||
func (e *MaxRestartsExceededError) Error() string {
|
||||
return "maximum restart attempts exceeded"
|
||||
}
|
||||
|
||||
// GetAttempts returns the number of restart attempts made.
|
||||
func (e *MaxRestartsExceededError) GetAttempts() int {
|
||||
return e.attempts
|
||||
}
|
||||
|
||||
// GetMaxAttempts returns the maximum allowed attempts.
|
||||
func (e *MaxRestartsExceededError) GetMaxAttempts() int {
|
||||
return e.maxAttempts
|
||||
}
|
||||
465
pkg/daemon/service.go
Normal file
465
pkg/daemon/service.go
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultPIDFileName is the default PID file name
|
||||
DefaultPIDFileName = "gateway.pid"
|
||||
// DefaultStateFileName is the default state file name
|
||||
DefaultStateFileName = "gateway-state.json"
|
||||
// DefaultLogFileName is the default log file name
|
||||
DefaultLogFileName = "gateway.log"
|
||||
)
|
||||
|
||||
// Service manages a daemon process with lifecycle control.
|
||||
//
|
||||
// Design rationale:
|
||||
// - Provides a high-level API for daemon operations (start, stop, restart, status)
|
||||
// - Encapsulates PID file, state management, and logging concerns
|
||||
// - Follows the same Start/Stop/IsRunning pattern as other services (heartbeat, cron)
|
||||
// - Thread-safe operations with proper mutex protection
|
||||
type Service struct {
|
||||
// configDir is the directory containing configuration and runtime files
|
||||
configDir string
|
||||
|
||||
// binaryPath is the path to the picoclaw binary
|
||||
binaryPath string
|
||||
|
||||
// args are the arguments to pass to the daemon process
|
||||
args []string
|
||||
|
||||
// version is the picoclaw version
|
||||
version string
|
||||
|
||||
pidFile *PIDFile
|
||||
state *StateManager
|
||||
logConfig *LogConfig
|
||||
restartPolicy *RestartPolicy
|
||||
|
||||
mu sync.Mutex
|
||||
quitChan chan struct{}
|
||||
}
|
||||
|
||||
// ServiceConfig is the configuration for creating a new Service.
|
||||
type ServiceConfig struct {
|
||||
// ConfigDir is the directory containing config and runtime files (~/.picoclaw)
|
||||
ConfigDir string
|
||||
|
||||
// BinaryPath is the path to the picoclaw binary
|
||||
BinaryPath string
|
||||
|
||||
// Args are additional arguments to pass to the gateway process
|
||||
Args []string
|
||||
|
||||
// Version is the picoclaw version
|
||||
Version string
|
||||
|
||||
// RestartPolicy is the restart policy (nil for default)
|
||||
RestartPolicy *RestartPolicy
|
||||
|
||||
// LogConfig is the logging configuration (nil for default)
|
||||
LogConfig *LogConfig
|
||||
}
|
||||
|
||||
// NewService creates a new daemon service with the given configuration.
|
||||
func NewService(config *ServiceConfig) (*Service, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("service config cannot be nil")
|
||||
}
|
||||
|
||||
pidFilePath := filepath.Join(config.ConfigDir, DefaultPIDFileName)
|
||||
stateFilePath := filepath.Join(config.ConfigDir, DefaultStateFileName)
|
||||
logFilePath := filepath.Join(config.ConfigDir, DefaultLogFileName)
|
||||
|
||||
logConfig := config.LogConfig
|
||||
if logConfig == nil {
|
||||
logConfig = DefaultLogConfig(logFilePath)
|
||||
}
|
||||
|
||||
return &Service{
|
||||
configDir: config.ConfigDir,
|
||||
binaryPath: config.BinaryPath,
|
||||
args: config.Args,
|
||||
version: config.Version,
|
||||
pidFile: NewPIDFile(pidFilePath),
|
||||
state: NewStateManager(stateFilePath),
|
||||
logConfig: logConfig,
|
||||
restartPolicy: config.RestartPolicy,
|
||||
quitChan: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start starts the gateway daemon.
|
||||
//
|
||||
// Returns an error if:
|
||||
// - The daemon is already running
|
||||
// - The binary cannot be executed
|
||||
// - PID file creation fails
|
||||
func (s *Service) Start() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
logger.InfoCF("daemon", "Starting gateway daemon", map[string]any{
|
||||
"version": s.version,
|
||||
"binary": s.binaryPath,
|
||||
})
|
||||
|
||||
// Check if already running
|
||||
if s.pidFile.IsProcessRunning() {
|
||||
pid := s.pidFile.Read()
|
||||
return &AlreadyRunningError{pid: pid}
|
||||
}
|
||||
|
||||
// Prepare command
|
||||
cmd := exec.Command(s.binaryPath, s.buildArgs()...)
|
||||
|
||||
// Set up environment for daemon mode
|
||||
// This tells the gateway process it's running as a daemon
|
||||
cmd.Env = append(os.Environ(), "PICOCLAW_DAEMON=1")
|
||||
|
||||
// Redirect stdout and stderr to log file
|
||||
logFile, err := os.OpenFile(s.logConfig.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open log file: %w", err)
|
||||
}
|
||||
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
// Start the process
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return fmt.Errorf("failed to start gateway process: %w", err)
|
||||
}
|
||||
|
||||
pid := cmd.Process.Pid
|
||||
|
||||
// Write PID file atomically
|
||||
if err := s.pidFile.Write(); err != nil {
|
||||
// Failed to write PID file, kill the process
|
||||
cmd.Process.Kill()
|
||||
logFile.Close()
|
||||
return fmt.Errorf("failed to write PID file: %w", err)
|
||||
}
|
||||
|
||||
// Update state
|
||||
s.state.SetPID(pid)
|
||||
s.state.SetStartTime(time.Now())
|
||||
s.state.SetVersion(s.version)
|
||||
|
||||
logger.InfoCF("daemon", "Gateway daemon started", map[string]any{
|
||||
"pid": pid,
|
||||
"version": s.version,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the running gateway daemon gracefully.
|
||||
//
|
||||
// Returns an error if:
|
||||
// - The daemon is not running
|
||||
// - The PID file cannot be read
|
||||
// - Signal delivery fails
|
||||
func (s *Service) Stop() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
logger.InfoC("daemon", "Stopping gateway daemon")
|
||||
|
||||
pid := s.pidFile.Read()
|
||||
if pid == 0 {
|
||||
return &NotRunningError{}
|
||||
}
|
||||
|
||||
// Send SIGTERM for graceful shutdown
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find process: %w", err)
|
||||
}
|
||||
|
||||
if err := process.Signal(syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("failed to send SIGTERM: %w", err)
|
||||
}
|
||||
|
||||
// Wait for process to exit (with timeout)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := process.Wait()
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Process exited
|
||||
logger.InfoCF("daemon", "Gateway daemon stopped", map[string]any{
|
||||
"pid": pid,
|
||||
})
|
||||
case <-time.After(30 * time.Second):
|
||||
// Timeout, force kill
|
||||
logger.WarnCF("daemon", "Gateway did not stop gracefully, forcing", map[string]any{
|
||||
"pid": pid,
|
||||
})
|
||||
process.Kill()
|
||||
}
|
||||
|
||||
// Clean up
|
||||
s.pidFile.Remove()
|
||||
s.state.Clear()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restart restarts the gateway daemon with automatic crash recovery.
|
||||
//
|
||||
// This method implements the auto-restart loop:
|
||||
// 1. If daemon is running, stop it first
|
||||
// 2. Start the daemon
|
||||
// 3. If it crashes, wait (exponential backoff) and restart
|
||||
// 4. Repeat up to MaxAttempts within WindowDuration
|
||||
// 5. Give up if max attempts exceeded
|
||||
//
|
||||
// Returns an error if:
|
||||
// - Stop fails
|
||||
// - Max restart attempts exceeded
|
||||
func (s *Service) Restart() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
logger.InfoC("daemon", "Restarting gateway daemon")
|
||||
|
||||
// Stop if running
|
||||
if s.pidFile.IsProcessRunning() {
|
||||
if err := s.Stop(); err != nil {
|
||||
return fmt.Errorf("failed to stop daemon: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create restart tracker
|
||||
tracker := NewRestartTracker(s.restartPolicy)
|
||||
|
||||
// Restart loop
|
||||
for tracker.ShouldRestart() {
|
||||
// Attempt to start
|
||||
err := s.startInternal()
|
||||
if err == nil {
|
||||
// Success! Reset restart counter and return
|
||||
tracker.Reset()
|
||||
logger.InfoC("daemon", "Gateway daemon restarted successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is an "already running" error
|
||||
if _, ok := err.(*AlreadyRunningError); ok {
|
||||
return err
|
||||
}
|
||||
|
||||
// Record the failed attempt and get backoff duration
|
||||
backoff, backoffErr := tracker.RecordAttempt()
|
||||
if backoffErr != nil {
|
||||
// Max attempts exceeded
|
||||
logger.ErrorCF("daemon", "Maximum restart attempts exceeded", map[string]any{
|
||||
"attempts": tracker.GetAttemptCount(),
|
||||
"max": s.restartPolicy.MaxAttempts,
|
||||
})
|
||||
return backoffErr
|
||||
}
|
||||
|
||||
// Update state with restart count
|
||||
s.state.IncrementRestartCount()
|
||||
|
||||
logger.WarnCF("daemon", "Gateway daemon crashed, will restart", map[string]any{
|
||||
"attempt": tracker.GetAttemptCount(),
|
||||
"backoff": backoff.String(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
|
||||
// Wait before next restart attempt
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
// Continue to next attempt
|
||||
case <-s.quitChan:
|
||||
// Abort restart loop
|
||||
return fmt.Errorf("restart aborted")
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("maximum restart attempts exceeded")
|
||||
}
|
||||
|
||||
// startInternal starts the daemon without locking.
|
||||
// Must be called with the lock held.
|
||||
func (s *Service) startInternal() error {
|
||||
// Prepare command
|
||||
cmd := exec.Command(s.binaryPath, s.buildArgs()...)
|
||||
cmd.Env = append(os.Environ(), "PICOCLAW_DAEMON=1")
|
||||
|
||||
// Redirect output to log file
|
||||
logFile, err := os.OpenFile(s.logConfig.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open log file: %w", err)
|
||||
}
|
||||
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
|
||||
// Start the process
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return fmt.Errorf("failed to start gateway process: %w", err)
|
||||
}
|
||||
|
||||
pid := cmd.Process.Pid
|
||||
|
||||
// Write PID file
|
||||
if err := s.pidFile.Write(); err != nil {
|
||||
cmd.Process.Kill()
|
||||
logFile.Close()
|
||||
return fmt.Errorf("failed to write PID file: %w", err)
|
||||
}
|
||||
|
||||
// Update state
|
||||
s.state.SetPID(pid)
|
||||
s.state.SetStartTime(time.Now())
|
||||
s.state.SetVersion(s.version)
|
||||
|
||||
// Wait for process to exit
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
// Status returns the current status of the gateway daemon.
|
||||
func (s *Service) Status() *StatusInfo {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
pid := s.pidFile.Read()
|
||||
isRunning := s.pidFile.IsProcessRunning()
|
||||
|
||||
state := s.state.GetState()
|
||||
|
||||
return &StatusInfo{
|
||||
IsRunning: isRunning,
|
||||
PID: pid,
|
||||
StartTime: state.StartTime,
|
||||
Uptime: s.state.GetUptime(),
|
||||
RestartCount: state.RestartCount,
|
||||
Version: state.Version,
|
||||
}
|
||||
}
|
||||
|
||||
// IsRunning returns true if the daemon is currently running.
|
||||
func (s *Service) IsRunning() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return s.pidFile.IsProcessRunning()
|
||||
}
|
||||
|
||||
// buildArgs constructs the command line arguments for the gateway process.
|
||||
func (s *Service) buildArgs() []string {
|
||||
args := append([]string{"gateway"}, s.args...)
|
||||
return args
|
||||
}
|
||||
|
||||
// Abort aborts any ongoing restart operation.
|
||||
func (s *Service) Abort() {
|
||||
close(s.quitChan)
|
||||
}
|
||||
|
||||
// StatusInfo contains status information about the daemon.
|
||||
type StatusInfo struct {
|
||||
IsRunning bool
|
||||
PID int
|
||||
StartTime time.Time
|
||||
Uptime time.Duration
|
||||
RestartCount int
|
||||
Version string
|
||||
}
|
||||
|
||||
// AlreadyRunningError is returned when attempting to start a daemon
|
||||
// that is already running.
|
||||
type AlreadyRunningError struct {
|
||||
pid int
|
||||
}
|
||||
|
||||
func (e *AlreadyRunningError) Error() string {
|
||||
return fmt.Sprintf("gateway already running with PID %d", e.pid)
|
||||
}
|
||||
|
||||
// GetPID returns the PID of the running process.
|
||||
func (e *AlreadyRunningError) GetPID() int {
|
||||
return e.pid
|
||||
}
|
||||
|
||||
// NotRunningError is returned when attempting to stop a daemon
|
||||
// that is not running.
|
||||
type NotRunningError struct{}
|
||||
|
||||
func (e *NotRunningError) Error() string {
|
||||
return "gateway is not running"
|
||||
}
|
||||
|
||||
// Run runs the gateway in the foreground (not as a daemon).
|
||||
// This is the existing behavior when running `picoclaw gateway` without subcommands.
|
||||
//
|
||||
// This method blocks until the gateway is stopped via Ctrl+C.
|
||||
// It sets up signal handlers for graceful shutdown and initializes
|
||||
// file logging for daemon mode.
|
||||
func (s *Service) Run(gatewayFunc func(context.Context) error) error {
|
||||
// Check if running as daemon
|
||||
isDaemon := os.Getenv("PICOCLAW_DAEMON") == "1"
|
||||
|
||||
if isDaemon {
|
||||
// Set up file logging
|
||||
daemonLogger, err := NewLogger(s.logConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize daemon logging: %w", err)
|
||||
}
|
||||
defer daemonLogger.Close()
|
||||
|
||||
// Redirect logger output to file
|
||||
if err := logger.EnableFileLogging(s.logConfig.Path); err != nil {
|
||||
return fmt.Errorf("failed to enable file logging: %w", err)
|
||||
}
|
||||
defer logger.DisableFileLogging()
|
||||
|
||||
logger.InfoCF("daemon", "Gateway started in daemon mode", map[string]any{
|
||||
"pid": os.Getpid(),
|
||||
"version": s.version,
|
||||
})
|
||||
}
|
||||
|
||||
// Create context with cancellation
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Run the gateway function in a goroutine
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
errChan <- gatewayFunc(ctx)
|
||||
}()
|
||||
|
||||
// Wait for context cancellation or error
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case err := <-errChan:
|
||||
return err
|
||||
}
|
||||
}
|
||||
236
pkg/daemon/state.go
Normal file
236
pkg/daemon/state.go
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// State represents the persistent state of a daemon process.
|
||||
//
|
||||
// Design rationale:
|
||||
// - Persistent state allows status reporting across process restarts
|
||||
// - Atomic saves prevent corruption from crashes or power loss
|
||||
// - Tracking restart history helps identify systemic issues
|
||||
type State struct {
|
||||
// PID is the process ID of the running daemon
|
||||
PID int `json:"pid"`
|
||||
|
||||
// StartTime is when the daemon was started
|
||||
StartTime time.Time `json:"start_time"`
|
||||
|
||||
// RestartCount is the number of times the daemon has been restarted
|
||||
// This is cumulative and is reset when the daemon runs successfully
|
||||
// for longer than the restart window duration.
|
||||
RestartCount int `json:"restart_count"`
|
||||
|
||||
// LastRestartTime is the timestamp of the most recent restart
|
||||
LastRestartTime *time.Time `json:"last_restart_time,omitempty"`
|
||||
|
||||
// Version is the picoclaw version that started this daemon
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
// StateManager manages daemon state with atomic saves.
|
||||
// Follows the same atomic save pattern as pkg/state/state.go
|
||||
type StateManager struct {
|
||||
stateFile string
|
||||
state *State
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewStateManager creates a new state manager for the given state file path.
|
||||
func NewStateManager(stateFile string) *StateManager {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(stateFile)
|
||||
os.MkdirAll(dir, 0o755)
|
||||
|
||||
sm := &StateManager{
|
||||
stateFile: stateFile,
|
||||
state: &State{},
|
||||
}
|
||||
|
||||
// Load existing state if present
|
||||
sm.load()
|
||||
|
||||
return sm
|
||||
}
|
||||
|
||||
// Save atomically saves the current state to disk.
|
||||
// Uses the temp file + rename pattern for atomic writes.
|
||||
func (sm *StateManager) Save() error {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
return sm.saveAtomic()
|
||||
}
|
||||
|
||||
// saveAtomic performs an atomic save using temp file + rename.
|
||||
// Must be called with the lock held.
|
||||
func (sm *StateManager) saveAtomic() error {
|
||||
// Create temp file in the same directory as the target
|
||||
tempFile := sm.stateFile + ".tmp"
|
||||
|
||||
// Marshal state to JSON
|
||||
data, err := json.MarshalIndent(sm.state, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal state: %w", err)
|
||||
}
|
||||
|
||||
// Write to temp file
|
||||
if err := os.WriteFile(tempFile, data, 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
|
||||
// Atomic rename from temp to target
|
||||
if err := os.Rename(tempFile, sm.stateFile); err != nil {
|
||||
// Cleanup temp file if rename fails
|
||||
os.Remove(tempFile)
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// load loads the state from disk.
|
||||
// Must be called with the lock held.
|
||||
func (sm *StateManager) load() error {
|
||||
data, err := os.ReadFile(sm.stateFile)
|
||||
if err != nil {
|
||||
// File doesn't exist yet, that's OK
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read state file: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, sm.state); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPID updates the PID in the state and saves atomically.
|
||||
func (sm *StateManager) SetPID(pid int) error {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
sm.state.PID = pid
|
||||
return sm.saveAtomic()
|
||||
}
|
||||
|
||||
// GetPID returns the PID from the state.
|
||||
func (sm *StateManager) GetPID() int {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
return sm.state.PID
|
||||
}
|
||||
|
||||
// SetStartTime sets the start time in the state and saves atomically.
|
||||
func (sm *StateManager) SetStartTime(startTime time.Time) error {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
sm.state.StartTime = startTime
|
||||
return sm.saveAtomic()
|
||||
}
|
||||
|
||||
// GetStartTime returns the start time from the state.
|
||||
func (sm *StateManager) GetStartTime() time.Time {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
return sm.state.StartTime
|
||||
}
|
||||
|
||||
// IncrementRestartCount increments the restart counter and saves atomically.
|
||||
func (sm *StateManager) IncrementRestartCount() error {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
sm.state.RestartCount++
|
||||
now := time.Now()
|
||||
sm.state.LastRestartTime = &now
|
||||
|
||||
return sm.saveAtomic()
|
||||
}
|
||||
|
||||
// GetRestartCount returns the restart count from the state.
|
||||
func (sm *StateManager) GetRestartCount() int {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
return sm.state.RestartCount
|
||||
}
|
||||
|
||||
// ResetRestartCount resets the restart counter to zero and saves atomically.
|
||||
// Call this when the daemon has been running successfully for a while
|
||||
// to indicate that crashes are no longer a concern.
|
||||
func (sm *StateManager) ResetRestartCount() error {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
sm.state.RestartCount = 0
|
||||
sm.state.LastRestartTime = nil
|
||||
|
||||
return sm.saveAtomic()
|
||||
}
|
||||
|
||||
// SetVersion sets the picoclaw version in the state and saves atomically.
|
||||
func (sm *StateManager) SetVersion(version string) error {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
sm.state.Version = version
|
||||
return sm.saveAtomic()
|
||||
}
|
||||
|
||||
// GetVersion returns the version from the state.
|
||||
func (sm *StateManager) GetVersion() string {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
return sm.state.Version
|
||||
}
|
||||
|
||||
// GetUptime returns the duration since the daemon started.
|
||||
// Returns zero if start time is not set.
|
||||
func (sm *StateManager) GetUptime() time.Duration {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
|
||||
if sm.state.StartTime.IsZero() {
|
||||
return 0
|
||||
}
|
||||
|
||||
return time.Since(sm.state.StartTime)
|
||||
}
|
||||
|
||||
// GetState returns a copy of the current state.
|
||||
func (sm *StateManager) GetState() State {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
|
||||
return *sm.state
|
||||
}
|
||||
|
||||
// Clear removes the state file entirely.
|
||||
// Use this when stopping the daemon gracefully.
|
||||
func (sm *StateManager) Clear() error {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
sm.state = &State{}
|
||||
|
||||
if err := os.Remove(sm.stateFile); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove state file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue