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
107
README.md
107
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
|
## 💬 Chat Apps
|
||||||
|
|
||||||
Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
|
Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
|
||||||
|
|
@ -1107,15 +1192,19 @@ picoclaw agent -m "Hello"
|
||||||
|
|
||||||
## CLI Reference
|
## CLI Reference
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------------------------- | ----------------------------- |
|
| ------------------------------- | ------------------------------------------ |
|
||||||
| `picoclaw onboard` | Initialize config & workspace |
|
| `picoclaw onboard` | Initialize config & workspace |
|
||||||
| `picoclaw agent -m "..."` | Chat with the agent |
|
| `picoclaw agent -m "..."` | Chat with the agent |
|
||||||
| `picoclaw agent` | Interactive chat mode |
|
| `picoclaw agent` | Interactive chat mode |
|
||||||
| `picoclaw gateway` | Start the gateway |
|
| `picoclaw gateway` | Start gateway in foreground |
|
||||||
| `picoclaw status` | Show status |
|
| `picoclaw gateway start` | Start gateway as daemon (background) |
|
||||||
| `picoclaw cron list` | List all scheduled jobs |
|
| `picoclaw gateway stop` | Stop the gateway daemon |
|
||||||
| `picoclaw cron add ...` | Add a scheduled job |
|
| `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 |
|
||||||
|
|
||||||
### Scheduled Tasks / Reminders
|
### Scheduled Tasks / Reminders
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/agent"
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
|
|
@ -28,27 +29,35 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/voice"
|
"github.com/sipeed/picoclaw/pkg/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
func gatewayCmd() {
|
// gatewayRunner holds the initialized gateway components.
|
||||||
// Check for --debug flag
|
// This allows the gateway lifecycle to be managed externally (e.g., by daemon service).
|
||||||
args := os.Args[2:]
|
type gatewayRunner struct {
|
||||||
for _, arg := range args {
|
cfg *config.Config
|
||||||
if arg == "--debug" || arg == "-d" {
|
provider providers.LLMProvider
|
||||||
logger.SetLevel(logger.DEBUG)
|
msgBus *bus.MessageBus
|
||||||
fmt.Println("🔍 Debug mode enabled")
|
agentLoop *agent.AgentLoop
|
||||||
break
|
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()
|
cfg, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error loading config: %v\n", err)
|
return nil, fmt.Errorf("error loading config: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
provider, modelID, err := providers.CreateProvider(cfg)
|
provider, modelID, err := providers.CreateProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error creating provider: %v\n", err)
|
return nil, fmt.Errorf("error creating provider: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
// Use the resolved model ID from provider creation
|
// Use the resolved model ID from provider creation
|
||||||
if modelID != "" {
|
if modelID != "" {
|
||||||
|
|
@ -58,24 +67,6 @@ func gatewayCmd() {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
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
|
// Setup cron tool and service
|
||||||
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
||||||
cronService := setupCronTool(
|
cronService := setupCronTool(
|
||||||
|
|
@ -114,8 +105,7 @@ func gatewayCmd() {
|
||||||
|
|
||||||
channelManager, err := channels.NewManager(cfg, msgBus)
|
channelManager, err := channels.NewManager(cfg, msgBus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error creating channel manager: %v\n", err)
|
return nil, fmt.Errorf("error creating channel manager: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject channel manager into agent loop for command handling
|
// 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())
|
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())
|
stateManager := state.NewManager(cfg.WorkspacePath())
|
||||||
deviceService := devices.NewService(devices.Config{
|
deviceService := devices.NewService(devices.Config{
|
||||||
|
|
@ -186,42 +155,183 @@ func gatewayCmd() {
|
||||||
MonitorUSB: cfg.Devices.MonitorUSB,
|
MonitorUSB: cfg.Devices.MonitorUSB,
|
||||||
}, stateManager)
|
}, stateManager)
|
||||||
deviceService.SetBus(msgBus)
|
deviceService.SetBus(msgBus)
|
||||||
if err := deviceService.Start(ctx); err != nil {
|
|
||||||
fmt.Printf("Error starting device service: %v\n", err)
|
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
} else if cfg.Devices.Enabled {
|
|
||||||
|
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")
|
fmt.Println("✓ Device event service started")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := channelManager.StartAll(ctx); err != nil {
|
// Start channels
|
||||||
fmt.Printf("Error starting channels: %v\n", err)
|
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() {
|
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()})
|
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)
|
// Wait for context cancellation
|
||||||
signal.Notify(sigChan, os.Interrupt)
|
<-r.ctx.Done()
|
||||||
<-sigChan
|
|
||||||
|
|
||||||
fmt.Println("\nShutting down...")
|
return nil
|
||||||
if cp, ok := provider.(providers.StatefulProvider); ok {
|
}
|
||||||
|
|
||||||
|
// stop gracefully stops all gateway services.
|
||||||
|
func (r *gatewayRunner) stop() {
|
||||||
|
logger.InfoC("gateway", "Shutting down...")
|
||||||
|
|
||||||
|
if !isDaemonMode() {
|
||||||
|
fmt.Println("\nShutting down...")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cp, ok := r.provider.(providers.StatefulProvider); ok {
|
||||||
cp.Close()
|
cp.Close()
|
||||||
}
|
}
|
||||||
cancel()
|
|
||||||
healthServer.Stop(context.Background())
|
r.cancel()
|
||||||
deviceService.Stop()
|
|
||||||
heartbeatService.Stop()
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
cronService.Stop()
|
defer cancel()
|
||||||
agentLoop.Stop()
|
|
||||||
channelManager.StopAll(ctx)
|
r.healthServer.Stop(ctx)
|
||||||
fmt.Println("✓ Gateway stopped")
|
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(
|
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":
|
case "agent":
|
||||||
agentCmd()
|
agentCmd()
|
||||||
case "gateway":
|
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()
|
gatewayCmd()
|
||||||
case "status":
|
case "status":
|
||||||
statusCmd()
|
statusCmd()
|
||||||
|
|
@ -178,15 +188,19 @@ func printHelp() {
|
||||||
fmt.Println("Usage: picoclaw <command>")
|
fmt.Println("Usage: picoclaw <command>")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Commands:")
|
fmt.Println("Commands:")
|
||||||
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
|
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
|
||||||
fmt.Println(" agent Interact with the agent directly")
|
fmt.Println(" agent Interact with the agent directly")
|
||||||
fmt.Println(" auth Manage authentication (login, logout, status)")
|
fmt.Println(" auth Manage authentication (login, logout, status)")
|
||||||
fmt.Println(" gateway Start picoclaw gateway")
|
fmt.Println(" gateway Start picoclaw gateway (foreground mode)")
|
||||||
fmt.Println(" status Show picoclaw status")
|
fmt.Println(" gateway start Start gateway as daemon (background)")
|
||||||
fmt.Println(" cron Manage scheduled tasks")
|
fmt.Println(" gateway stop Stop the gateway daemon")
|
||||||
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
|
fmt.Println(" gateway restart Restart the gateway daemon")
|
||||||
fmt.Println(" skills Manage skills (install, list, remove)")
|
fmt.Println(" gateway status Show gateway daemon status")
|
||||||
fmt.Println(" version Show version information")
|
fmt.Println(" status Show picoclaw status")
|
||||||
|
fmt.Println(" cron Manage scheduled tasks")
|
||||||
|
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
|
||||||
|
fmt.Println(" skills Manage skills (install, list, remove)")
|
||||||
|
fmt.Println(" version Show version information")
|
||||||
}
|
}
|
||||||
|
|
||||||
func getConfigPath() string {
|
func getConfigPath() string {
|
||||||
|
|
|
||||||
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