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:
Vishnuvardhan Reddy 2026-02-24 18:14:24 +00:00
parent fd26fa7459
commit f62439c8d6
9 changed files with 1869 additions and 95 deletions

View file

@ -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 |

View file

@ -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
View 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)
}

View file

@ -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
View 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
View 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
View 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
View 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
View 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
}