daemon

package
v0.17.18 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 24, 2026 License: MIT Imports: 21 Imported by: 0

Documentation

Overview

Package daemon — Phase 2: Daemon lifecycle + auto-start machinery.

The functions in this file (DaemonSpec, DetectDaemon, StartDaemon, EnsureDaemon) provide the orchestration logic to detect an existing daemon, spawn one if needed (with cross-process flock election), and return control to the caller. The DaemonLifecycle type in lifecycle.go tracks active connections and reaps idle daemons.

Package design: pkg/daemon is intentionally lean — it depends only on stdlib, github.com/gofrs/flock, and pkg/envutil (stdlib-only env helper). No import of pkg/webui, pkg/agent, or pkg/agent_tools.

Package daemon provides client-side health monitoring for the sprout daemon.

This is the Phase-1 client-side health-detection mechanism for SP-136. The CLI-on-daemon feature (Phase 2+) will call MonitorDaemonHealth before or while using the daemon. The fallback callback should switch the caller to in-process execution. No wiring into cmd/agent_command.go is done yet — the mechanism is built as a standalone, well-tested package ready for Phase 2 integration.

Index

Constants

View Source
const DefaultFailureThreshold = 3

DefaultFailureThreshold is the default number of consecutive failures before the fallback callback is invoked (3).

View Source
const DefaultHealthInterval = 5 * time.Second

DefaultHealthInterval is the default interval between health checks (5s).

View Source
const DefaultHealthTimeout = 2 * time.Second

DefaultHealthTimeout is the default timeout for each individual health check (2s).

View Source
const DefaultRemoteSocketTimeout = 10 * time.Minute

DefaultRemoteSocketTimeout bounds a single remote operation. Agent runs can take a while, but a stuck daemon must not hang the CLI.

Variables

View Source
var ErrDaemonDisabled = errors.New("daemon disabled via SPROUT_DAEMON=0")

ErrDaemonDisabled is returned by EnsureDaemon when SPROUT_DAEMON=0. Callers should treat this as "daemon disabled" and fall back to in-process execution.

Functions

func DetectDaemon

func DetectDaemon(ctx context.Context, spec DaemonSpec) (bool, error)

DetectDaemon checks whether a healthy daemon is already reachable.

It tries GET on DaemonURL first (HTTP TCP). If that fails and SocketPath is non-empty, it tries a Unix-domain socket connection. Healthy means HTTP 200 from the /health endpoint (matching the check in health.go).

func EnsureDaemon

func EnsureDaemon(ctx context.Context, spec DaemonSpec) (alreadyRunning bool, err error)

EnsureDaemon ensures a healthy daemon is running. It uses the PID file as a flock-based election primitive: exactly one process spawns the daemon; competing processes wait for it to become healthy.

Election protocol (single-shot TryLock):

  • Winner (TryLock == true): writes PID, spawns daemon via startDaemonInner, polls health, then releases the flock after the daemon is healthy.
  • Loser (TryLock == false): does NOT call Unlock (never held the lock). Polls DetectDaemon every ~300ms up to StartTimeout. If the daemon becomes healthy → returns (false, nil). On timeout or ctx cancellation → returns an error.

The escape hatch: if SPROUT_DAEMON env is "0", returns ErrDaemonDisabled.

Returns (alreadyRunning=true, nil) if a daemon was already reachable before this call. Returns (alreadyRunning=false, nil) if this call started the daemon and it became healthy, or if another process started it and this call only waited for it.

The flock guards the election only, not the daemon's lifetime. A stale PID file from a crashed process is handled automatically because the OS releases the flock when the process exits.

func PreferOOMVictim

func PreferOOMVictim() error

PreferOOMVictim raises this process's oom_score_adj so the kernel is more likely to choose it as an OOM victim over user-facing processes, which stay at the default 0. A long-lived background helper holding a large working set should sacrifice itself before the kernel takes a user-facing process. No-op on platforms without oom_score_adj; the error is non-fatal and callers should log and continue.

func StartDaemon

func StartDaemon(ctx context.Context, spec DaemonSpec) error

StartDaemon spawns the daemon process (detached) and polls until it is healthy or StartTimeout elapses. The spawned daemon is NOT tied to the caller's context lifetime — it survives ctx cancellation and the caller exiting. Returns an error on timeout, ctx cancellation, or if the process fails to start.

Types

type AgentClient

type AgentClient struct {
	// contains filtered or unexported fields
}

AgentClient is the CLI-side thin client for the daemon's agent socket (SP-136 P4). The daemon owns agent state, conversation history, and tool dispatch; the CLI renders responses. Falls back to in-process execution at the caller's discretion when the socket is unavailable.

func NewAgentClient

func NewAgentClient(socketPath string) (*AgentClient, error)

NewAgentClient dials the daemon agent socket. Returns an error when the daemon is not reachable — callers then fall back to in-process execution.

func (*AgentClient) Close

func (c *AgentClient) Close() error

Close closes the underlying connection.

func (*AgentClient) CreateSession

func (c *AgentClient) CreateSession(ctx context.Context, name string) (*SessionInfo, error)

CreateSession creates a named session.

func (*AgentClient) ExecuteTool

func (c *AgentClient) ExecuteTool(ctx context.Context, name string, args map[string]any) (*ToolResult, error)

ExecuteTool invokes a tool on the daemon.

func (*AgentClient) ListSessions

func (c *AgentClient) ListSessions(ctx context.Context) ([]SessionInfo, error)

ListSessions returns known sessions.

func (*AgentClient) Query

func (c *AgentClient) Query(ctx context.Context, prompt, workDir string) (string, error)

Query runs a one-shot query on the daemon and returns the final response. workDir is the caller's working directory, required so the daemon (a single long-lived process that may serve many different projects over its lifetime) scopes tool execution to the right one.

func (*AgentClient) StreamQuery

func (c *AgentClient) StreamQuery(ctx context.Context, prompt, workDir string, emit func(StreamEvent) error) error

StreamQuery is Query with streamed events instead of a single result. The call returns after the terminal "done"/"error" event.

func (*AgentClient) SwitchSession

func (c *AgentClient) SwitchSession(ctx context.Context, sessionID string) (*SessionInfo, error)

SwitchSession activates an existing session.

type AgentOp

type AgentOp string

AgentOp identifies a protocol operation.

const (
	AgentOpListSessions  AgentOp = "list_sessions"
	AgentOpCreateSession AgentOp = "create_session"
	AgentOpSwitchSession AgentOp = "switch_session"
	AgentOpQuery         AgentOp = "query"
	AgentOpStreamQuery   AgentOp = "stream_query"
	AgentOpExecuteTool   AgentOp = "execute_tool"
)

type AgentRequest

type AgentRequest struct {
	ID     string  `json:"id"`
	Op     AgentOp `json:"op"`
	Prompt string  `json:"prompt,omitempty"`
	// WorkDir is the caller's working directory for query/query_stream ops.
	// The daemon has no other way to know which project a one-shot query is
	// for: it's a single long-lived process that may serve callers from many
	// different directories over its lifetime. Required for those ops —
	// AgentService implementations must scope tool execution to WorkDir
	// rather than the daemon process's own (fixed, arbitrary) cwd.
	WorkDir     string         `json:"work_dir,omitempty"`
	SessionName string         `json:"session_name,omitempty"`
	SessionID   string         `json:"session_id,omitempty"`
	Tool        string         `json:"tool,omitempty"`
	ToolArgs    map[string]any `json:"tool_args,omitempty"`
}

AgentRequest is a single protocol request.

type AgentResponse

type AgentResponse struct {
	ID       string        `json:"id"`
	Error    string        `json:"error,omitempty"`
	Sessions []SessionInfo `json:"sessions,omitempty"`
	Session  *SessionInfo  `json:"session,omitempty"`
	Result   string        `json:"result,omitempty"`
	Tool     *ToolResult   `json:"tool,omitempty"`
}

AgentResponse is a single protocol response (non-streaming ops).

type AgentServer

type AgentServer struct {
	// SocketPath is the Unix socket path to listen on.
	SocketPath string
	// Service backs all operations.
	Service AgentService
	// Logger receives request/error logs.
	Logger *slog.Logger
	// Activity, when non-nil, is marked Begin/End around each request so the
	// daemon idle reaper can see agent socket traffic.
	Activity *DaemonActivity
	// OnClose, when non-nil, is invoked inside Close() after the listener and
	// conns are closed, before Close returns. It blocks until the service's
	// teardown finishes — mirroring webui's waitForAgentTeardown so a daemon
	// exiting doesn't race an in-flight embedding-store flush.
	OnClose func()
	// contains filtered or unexported fields
}

AgentServer serves the SP-136 P4 agent socket protocol.

func (*AgentServer) Close

func (s *AgentServer) Close() error

Close stops the server and drops connections.

func (*AgentServer) Start

func (s *AgentServer) Start(ctx context.Context) error

Start begins listening. Returns once bound.

func (*AgentServer) Wait

func (s *AgentServer) Wait()

Wait blocks until the server stops accepting.

type AgentService

type AgentService interface {
	// ListSessions returns known sessions.
	ListSessions(ctx context.Context) ([]SessionInfo, error)
	// CreateSession creates a named session.
	CreateSession(ctx context.Context, name string) (*SessionInfo, error)
	// SwitchSession activates an existing session.
	SwitchSession(ctx context.Context, sessionID string) (*SessionInfo, error)
	// Query runs a one-shot query scoped to workDir and returns the final
	// response. Implementations must not let query state (conversation
	// history, workspace root) leak between calls with different workDir —
	// each call may be for an unrelated project.
	Query(ctx context.Context, prompt, workDir string) (string, error)
	// StreamQuery is Query with streamed events instead of a single result.
	StreamQuery(ctx context.Context, prompt, workDir string, emit func(StreamEvent) error) error
	// ExecuteTool invokes a tool by name with args.
	ExecuteTool(ctx context.Context, name string, args map[string]any) (*ToolResult, error)
}

AgentService is the daemon-side capability for CLI-on-daemon (SP-136 P4).

type DaemonActivity

type DaemonActivity struct {
	// contains filtered or unexported fields
}

DaemonActivity tracks whether a daemon subsystem is in use so the idle reaper (cmd/daemon_idle.go) can see socket-server traffic it would otherwise be blind to.

Two signals feed Idle:

  • Begin/End maintain an in-flight request counter; any request being served counts as activity no matter how long it runs.
  • Touch (and Begin/End) record the last-activity timestamp, so a request that just completed still counts as activity until the window elapses — bursty callers don't get the daemon torn down between requests.

func NewDaemonActivity

func NewDaemonActivity() *DaemonActivity

NewDaemonActivity returns an activity tracker with no activity recorded.

func (*DaemonActivity) Begin

func (a *DaemonActivity) Begin()

Begin marks a request as in flight.

func (*DaemonActivity) End

func (a *DaemonActivity) End()

End marks an in-flight request complete.

func (*DaemonActivity) Idle

func (a *DaemonActivity) Idle(now time.Time, window time.Duration) bool

Idle reports whether the subsystem has no in-flight work and has seen no activity within window.

func (*DaemonActivity) Touch

func (a *DaemonActivity) Touch()

Touch records that activity happened now.

type DaemonLifecycle

type DaemonLifecycle struct {
	// contains filtered or unexported fields
}

DaemonLifecycle manages the connection lifetime of a daemon process.

Connections are tracked via Add/Remove. When the last connection disconnects, a shutdown delay timer starts. If a new connection arrives before the timer fires, the timer is cancelled. When the timer fires (with no active connections), the StopFunc is invoked exactly once.

Typical flow:

lc := NewDaemonLifecycle(60*time.Second, func() error {
    return daemonProcess.Kill()
})
lc.Add() // first connection
lc.Add() // second connection (count → 2)
lc.Remove() // one disconnects (count → 1)
lc.Remove() // last disconnect (count → 0, 60s timer starts)
lc.Add() // new connection (count → 1, timer cancelled)
lc.Close() // clean up when shutting down

func NewDaemonLifecycle

func NewDaemonLifecycle(shutdownDelay time.Duration, stopFunc func() error) *DaemonLifecycle

NewDaemonLifecycle creates a lifecycle manager with the given shutdown delay and stop function. The stop function is called exactly once when the last connection disconnects and the delay expires.

func (*DaemonLifecycle) Add

func (l *DaemonLifecycle) Add() int

Add registers a new active connection. Returns the new count. If the shutdown delay timer is pending (count was 0), the timer is stopped — the daemon stays alive.

func (*DaemonLifecycle) Close

func (l *DaemonLifecycle) Close() error

Close stops the internal timer and releases resources. It is safe to call multiple times and does NOT invoke StopFunc (callers manage the daemon process lifecycle independently).

func (*DaemonLifecycle) Count

func (l *DaemonLifecycle) Count() int

Count returns the current number of active connections.

func (*DaemonLifecycle) Remove

func (l *DaemonLifecycle) Remove() int

Remove deregisters a connection. Returns the new count. If the count reaches 0, the shutdown delay timer starts.

func (*DaemonLifecycle) TimerActive

func (l *DaemonLifecycle) TimerActive() bool

TimerActive reports whether the shutdown delay timer is currently pending (count is 0 and timer hasn't fired yet). Useful for tests to verify the timer was started correctly.

type DaemonSpec

type DaemonSpec struct {
	// DaemonURL is the HTTP base URL to reach the daemon (e.g.
	// "http://127.0.0.1:56000").
	DaemonURL string

	// SocketPath is the Unix-domain socket path as an alternative to
	// TCP. If set, DetectDaemon tries this socket when DaemonURL fails.
	SocketPath string

	// PIDFilePath is the path to the PID-file lock used for cross-process
	// election. A flock on this file ensures exactly one process spawns
	// the daemon.
	PIDFilePath string

	// StartTimeout limits how long StartDaemon and EnsureDaemon wait for
	// the daemon to become healthy after spawning.
	StartTimeout time.Duration

	// ShutdownDelay is the idle timeout used by DaemonLifecycle: when
	// the last connection disconnects, the daemon is stopped after this
	// delay unless a new connection arrives.
	ShutdownDelay time.Duration

	// DaemonCommand is the command (args[0] is the binary) used to spawn
	// the daemon process. Default: [executable-path, "agent", "-d"].
	DaemonCommand []string

	// Env holds extra KEY=VALUE environment variables applied to the
	// spawned daemon process (merged over the parent's environment).
	// Used e.g. to set SPROUT_DAEMON_IDLE_TIMEOUT so auto-started
	// daemons reap themselves after an idle period.
	Env []string

	// LogPath is where the spawned daemon's stdout/stderr are redirected.
	// If empty, output goes to os.DevNull.
	LogPath string
}

DaemonSpec configures the daemon lifecycle (URL, socket, PID file, spawn command, and timeouts). Zero-value fields are replaced with defaults by DefaultDaemonSpec(); callers may override any field.

func DefaultDaemonSpec

func DefaultDaemonSpec() DaemonSpec

DefaultDaemonSpec returns a DaemonSpec with all fields set to their conventional defaults. Callers may then override individual fields (e.g. SocketPath for test isolation).

type EmbeddingManagerService

type EmbeddingManagerService struct {
	// Acquire returns a manager for the workspace root, or an error when
	// the workspace is refused (e.g. the index is not enabled). A nil
	// manager with a nil error means no manager is available at all. The
	// daemon wires this to embedding.AcquireManager with the workspace's
	// config.
	Acquire func(workspaceRoot string) (*embedding.EmbeddingManager, error)
	// Release drops a reference taken by Acquire.
	Release func(m *embedding.EmbeddingManager)
	// contains filtered or unexported fields
}

EmbeddingManagerService adapts a shared *embedding.EmbeddingManager to the socket protocol. Manager acquisition is workspace-scoped: each workspace root resolves to the daemon's shared manager (sole writer per index).

func (*EmbeddingManagerService) BuildIndex

func (s *EmbeddingManagerService) BuildIndex(ctx context.Context, workspaceRoot string) (*embedding.IndexStats, error)

BuildIndex implements EmbeddingService.

func (*EmbeddingManagerService) CheckDuplicates

func (s *EmbeddingManagerService) CheckDuplicates(ctx context.Context, workspaceRoot, filePath, content string) (*embedding.CheckDuplicatesResult, error)

CheckDuplicates implements EmbeddingService.

func (*EmbeddingManagerService) Embed

func (s *EmbeddingManagerService) Embed(ctx context.Context, text string) ([]float32, error)

Embed implements EmbeddingService.

func (*EmbeddingManagerService) EmbedBatch

func (s *EmbeddingManagerService) EmbedBatch(ctx context.Context, texts []string) ([][]float32, error)

EmbedBatch implements EmbeddingService.

func (*EmbeddingManagerService) Meta

Meta implements EmbeddingService.

func (*EmbeddingManagerService) QuerySimilar

func (s *EmbeddingManagerService) QuerySimilar(ctx context.Context, workspaceRoot, text string, topK int, threshold float32) ([]embedding.QueryResult, error)

QuerySimilar implements EmbeddingService.

type EmbeddingServer

type EmbeddingServer struct {
	// SocketPath is the Unix socket path to listen on.
	SocketPath string
	// Service backs all operations.
	Service EmbeddingService
	// Logger receives request/error logs.
	Logger *slog.Logger
	// Activity, when non-nil, is marked Begin/End around each request so the
	// daemon idle reaper can see embedding socket traffic.
	Activity *DaemonActivity
	// contains filtered or unexported fields
}

EmbeddingServer serves the SP-136 P3 JSON-over-Unix-socket embedding protocol. The daemon owns the sole model copy and the sole writer per workspace index; CLI processes talk to this server instead of loading their own model.

func (*EmbeddingServer) Close

func (s *EmbeddingServer) Close() error

Close stops the server, closes the listener, and drops all client connections.

func (*EmbeddingServer) Start

func (s *EmbeddingServer) Start(ctx context.Context) error

Start begins listening and serving. It returns once the listener is bound (not when serving stops — use Wait). Callers should call Close to stop.

func (*EmbeddingServer) Wait

func (s *EmbeddingServer) Wait()

Wait blocks until the server has stopped accepting (after Close or ctx cancellation).

type EmbeddingService

type EmbeddingService interface {
	// Meta returns provider identity (name, dimensions, model hash).
	Meta(ctx context.Context) (name string, dims int, modelHash string, err error)
	// Embed returns a vector for one text.
	Embed(ctx context.Context, text string) ([]float32, error)
	// EmbedBatch returns vectors for many texts (same order).
	EmbedBatch(ctx context.Context, texts []string) ([][]float32, error)
	// QuerySimilar queries the index for workspaceRoot.
	QuerySimilar(ctx context.Context, workspaceRoot, text string, topK int, threshold float32) ([]embedding.QueryResult, error)
	// BuildIndex builds the index for workspaceRoot.
	BuildIndex(ctx context.Context, workspaceRoot string) (*embedding.IndexStats, error)
	// CheckDuplicates checks content against the workspaceRoot index.
	CheckDuplicates(ctx context.Context, workspaceRoot, filePath, content string) (*embedding.CheckDuplicatesResult, error)
}

EmbeddingService is the daemon-side capability the socket protocol serves. Implemented by EmbeddingManagerService, which adapts the daemon's shared EmbeddingManager (sole model copy, sole index writer, inference gate).

type HealthMonitor

type HealthMonitor struct {
	// contains filtered or unexported fields
}

HealthMonitor periodically pings the daemon's /health endpoint and tracks consecutive failures. On reaching the failure threshold, it invokes the fallback callback (e.g. to switch to in-process execution).

The monitor is safe for concurrent use: Start/Stop are idempotent, and ConsecutiveFailures returns the latest atomic value.

func MonitorDaemonHealth

func MonitorDaemonHealth(ctx context.Context, baseURL string, fallback func(reason string)) *HealthMonitor

MonitorDaemonHealth creates a HealthMonitor with default settings, starts it, and returns it. The fallback callback is invoked when the failure threshold is reached. Warnings are logged via slog.

The returned monitor should be stopped (or its context cancelled) when the caller no longer needs monitoring. Example:

mon := MonitorDaemonHealth(ctx, "http://localhost:56000", func(reason string) {
    // switch to in-process execution
})
defer mon.Stop()

func NewHealthMonitor

func NewHealthMonitor(baseURL string, opts ...HealthOption) *HealthMonitor

NewHealthMonitor creates a monitor configured with the given baseURL and options. Default values: interval=5s, timeout=2s, threshold=3. The monitor does not start automatically — call Start(ctx).

func (*HealthMonitor) ConsecutiveFailures

func (m *HealthMonitor) ConsecutiveFailures() int

ConsecutiveFailures returns the current consecutive-failure count (atomic read). Useful for tests to assert failure tracking.

func (*HealthMonitor) Start

func (m *HealthMonitor) Start(ctx context.Context)

Start launches the health-check goroutine. It polls /health on the configured interval until ctx is cancelled or Stop() is called.

Start is idempotent: calling it twice is safe (the second call is a no-op). The startOnce field (not a local once) guarantees only ONE goroutine ever runs, so stopDone is closed exactly once.

func (*HealthMonitor) Stop

func (m *HealthMonitor) Stop()

Stop signals the monitor goroutine to exit. It is safe to call multiple times (sync.Once) and does not block on the goroutine exiting.

func (*HealthMonitor) WaitStop

func (m *HealthMonitor) WaitStop()

WaitStop blocks until the monitor goroutine has exited. Safe to call after Stop() or after the context passed to Start() is cancelled.

type HealthOption

type HealthOption func(*HealthMonitor)

HealthOption configures a HealthMonitor.

func WithFailureThreshold

func WithFailureThreshold(n int) HealthOption

WithFailureThreshold sets the number of consecutive failures before the fallback callback is invoked.

func WithFallbackFunc

func WithFallbackFunc(fn func(reason string)) HealthOption

WithFallbackFunc sets a callback invoked when the failure threshold is reached. The callback receives a descriptive reason string.

func WithInterval

func WithInterval(d time.Duration) HealthOption

WithInterval sets the polling interval between health checks.

func WithLogger

func WithLogger(logger *slog.Logger) HealthOption

WithLogger sets a custom logger for health check output.

func WithTimeout

func WithTimeout(d time.Duration) HealthOption

WithTimeout sets the timeout for each individual health check.

func WithWarningFunc

func WithWarningFunc(fn func(string)) HealthOption

WithWarningFunc sets a callback invoked on each health check failure (before the threshold is reached).

type HealthStatus

type HealthStatus struct {
	Status         string `json:"status"`
	Port           int    `json:"port"`
	Uptime         string `json:"uptime"`
	AgentAvailable bool   `json:"agent_available"`
	ActiveQueries  int    `json:"active_queries"`
}

HealthStatus is decoded from the JSON response of GET /health. Fields map 1:1 to the fields returned by the daemon's health endpoint. Unknown or missing fields are silently ignored (defensive decoding).

func CheckHealth

func CheckHealth(ctx context.Context, baseURL string, timeout time.Duration) (*HealthStatus, error)

CheckHealth performs a single GET against baseURL+"/health" with the given timeout. On HTTP 200 the JSON body is decoded into a HealthStatus and returned. Any other status code, network error, or JSON decode error returns a descriptive error.

Each check uses a FRESH connection (no keep-alive pooling). A pooled keep-alive connection to a server that has since exited masks the port state — the retry dial can hit a reused ephemeral port and return a stale "ok" — which makes daemon-gone detection (idle reap, crash) unreliable. Health checks are infrequent, so the dial cost is negligible.

Use http.NewRequestWithContext internally so the caller's context cancellation is respected.

type SessionInfo

type SessionInfo struct {
	ID        string `json:"id"`
	Name      string `json:"name"`
	CreatedAt string `json:"created_at,omitempty"`
	Active    bool   `json:"active,omitempty"`
}

SessionInfo describes an agent session.

type StreamEvent

type StreamEvent struct {
	Type    string `json:"type"` // "delta" | "tool" | "done" | "error"
	Content string `json:"content,omitempty"`
	Tool    string `json:"tool,omitempty"`
	Error   string `json:"error,omitempty"`
}

StreamEvent is one chunk of a streaming query run.

type ToolResult

type ToolResult struct {
	Content string `json:"content"`
	Error   string `json:"error,omitempty"`
}

ToolResult is the outcome of an ExecuteTool call.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL