daemon

package
v0.8.0 Latest Latest
Warning

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

Go to latest
Published: Aug 10, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package daemon implements SYNC-04/SYNC-05/D-05: the long-lived local process (or in-process fallback) that owns a repo's watcher plus the single GraphStore.Writer so multiple agent sessions share one indexer. A pid+start-timestamp lockfile in .codegraph/ guards the single-writer invariant (INDX-05); Unlock clears ONLY a genuinely-stale lock, never a live daemon's.

internal/daemon depends only on internal/indexer and internal/watch — indexer.Sync owns its own GraphStore.Open/Close/Writer lifecycle internally, so this package never imports internal/graphstore or Pebble directly.

Index

Constants

This section is empty.

Variables

View Source
var ErrLockLive = errors.New("daemon: lock is held by a live process")

ErrLockLive is returned by acquire and Unlock when the lockfile names a still-live process — the lock must never be silently cleared out from under a running daemon (T-04-07-01).

View Source
var ErrNotInitialized = errors.New("daemon: not initialized")

ErrNotInitialized mirrors the internal/cli and internal/query sentinels of the same name — returned by New when repoRoot has no .codegraph/ yet.

View Source
var ErrWatcherClosed = errors.New("daemon: watcher event stream closed unexpectedly")

ErrWatcherClosed is returned by Run when the watch loop exits without ctx being cancelled — fsnotify's Events/Errors channels closed abnormally (03-REVIEW.md IN-07). Without this, Run would keep blocking on <-ctx.Done() holding the daemon lockfile with no watcher running: a silent zombie lock-holder every other session's RunWithRetry defers to forever (pid alive, so isStale never clears it) while the graph silently stops auto-updating. It is neither ErrLockLive nor watch.ErrWatchDisabled, so RunWithRetry surfaces it immediately and serve's watcher goroutine logs it to stderr.

Functions

func Deregister

func Deregister(pid int) error

Deregister removes pid's record from the global registry (D-06), mirroring lock.go's release(): a missing file is a nil (success) no-op, not an error.

func Register

func Register(rec Record) error

Register atomically writes this daemon's record to the global registry (D-04/D-06), keyed by pid — never merged with any other daemon's record, including one for the same RepoRoot. fsatomic.WriteFile creates the registry dir if needed and guarantees a mid-write reader never observes a partial file.

func RunWithRetry

func RunWithRetry(ctx context.Context, d *Daemon, interval time.Duration, onDeferred func()) error

RunWithRetry drives d.Run(ctx) in a loop, converging concurrent serve --mcp sessions on a single writer (WATCH-04/D-14): defer-and-retry replaces the prior defer-once behavior, so a session that lost the race for the lock does not give up forever — it retries on a jittered cadence until either it acquires the lock (a surviving session becomes the sole writer once the holder exits) or ctx is cancelled.

On ErrLockLive, onDeferred is invoked once per retry (the caller logs the "deferring to it" line and may no-op subsequent calls) and the loop then sleeps jitter(interval), honoring ctx.Done() so a cancellation during the sleep returns ctx.Err() promptly rather than waiting out the interval. Any other outcome — nil (clean shutdown), watch.ErrWatchDisabled, or a genuine non-ErrLockLive error — returns immediately without ever calling onDeferred: policy is terminal (it doesn't change mid-session) and a genuine error is not something retrying can fix.

D-16 confirms acquire() (lock.go) self-heals a stale lock on every independent call — a crashed holder is detected and cleared the very next retry's acquire(), not by any new liveness/staleness machinery added here. This loop is therefore nothing more than "call Run again": no poll, no wait-for-pid, no watch-for-exit.

func Unlock

func Unlock(codegraphDir string) (string, error)

Unlock implements `codegraph unlock`'s engine (SYNC-05): it removes the daemon lockfile at codegraphDir ONLY when it is genuinely stale (T-04-07-01). An absent lockfile is a clean no-op; a live lock is left untouched and reported via ErrLockLive. The returned message is a human-readable summary of what happened, for a thin CLI layer (Plan 04-08) to print verbatim.

Types

type Daemon

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

Daemon is the long-lived local process (or in-process fallback, D-05) that owns a repo's watcher and drives every debounced flush through indexer.Sync — the single coordinated writer multiple agent sessions share (SYNC-04, INDX-05). Construct with New, then call Run(ctx); Run blocks until ctx is cancelled and every spawned goroutine has joined (D-07).

func New

func New(repoRoot string, opts indexer.Options, options ...Option) (*Daemon, error)

New resolves repoRoot's .codegraph/ layout and returns a Daemon ready to Run. It does not touch the lockfile or open the store — those happen inside Run, so a not-yet-started Daemon never holds any process-wide resource. New fails with ErrNotInitialized if repoRoot has no .codegraph/ (mirrors internal/cli's index/sync guidance: run `codegraph init` first). opts (WR-04) is threaded through to every debounced indexer.Sync call this Daemon drives (flush, below) — e.g. Workers to bound the daemon's own extraction pool independently of the CLI's one-shot `codegraph sync`/`index` invocations.

func (*Daemon) Run

func (d *Daemon) Run(ctx context.Context) error

Run acquires the daemon lockfile (single-writer invariant, D-05), opens a recursive watcher over repoRoot, and drives every debounced flush through indexer.Sync — touching the .sync-pending sidecar on the first pending event and removing it on a successful commit (D-04a). Run blocks until ctx is cancelled; it releases the lock and returns only after the watcher goroutine it spawned has joined (sync.WaitGroup, D-07) AND any debounce flush already in flight — including its indexer.Sync call — has completed (deb.Wait(), CR-01) — no goroutine outlives Run and no Sync is still writing when the lock is released (SYNC-06, INDX-05). If another live daemon already holds the lock, Run returns ErrLockLive immediately without starting a watcher. If the watch loop exits without ctx being cancelled (abnormal fsnotify teardown, IN-07), Run tears down through the same join path, releases the lock, and returns ErrWatcherClosed instead of holding the lock as a zombie — though on that path (ctx still live) an in-flight lock-lost flush can extend the join by a bounded requeue chain; see the backstop deb.Stop() comment in the function body (IN-01).

Before any of the above, Run enforces watch.WatchDisabledReason as its FIRST action (WATCH-03/D-11): a policy-disabled Daemon returns a watch.ErrWatchDisabled-wrapped error and never calls acquire(), so a disabled watcher never touches the lockfile. This is the single shared enforcement point both the in-process `serve --mcp` watcher (03-03) and the standalone `codegraph daemon` command (internal/cli/daemon.go, unchanged) inherit through this one call.

type Option

type Option func(*Daemon)

Option customizes a Daemon constructed via New. The variadic parameter keeps New's existing two-argument call sites (internal/cli/daemon.go) source- and binary-compatible — passing zero Options is a no-op.

func WithProbe

func WithProbe(p watch.Probe) Option

WithProbe overrides the Daemon's watch.Probe (see the probe field's doc comment). This is the only Option this plan introduces.

type Record

type Record struct {
	PID       int       `json:"pid"`
	StartedAt time.Time `json:"startedAt"`
	RepoRoot  string    `json:"repoRoot"`
}

Record is the JSON payload of one ~/.codegraph/daemons/<pid>.json entry (D-04): the minimum a cross-project daemon picker (07-07) or `daemon stop --all` (07-04) needs to identify and act on a running daemon.

func List

func List() ([]Record, error)

List reads every record in the global registry and self-heals on this SAME call (D-05) — no background reaper: each record's pid is checked against lock.go's isStale/isProcessLive (same package, unexported, no second liveness implementation), and any record found stale is removed from disk and excluded, mirroring acquire()'s existing detect-and-clear-on-every-independent-call discipline (Phase 4 D-16), generalized from one lockfile to many. An absent or empty registry dir is not an error — it returns (nil, nil). A file that vanishes between ReadDir and ReadFile (raced by a concurrent Deregister/prune) or a malformed record is skipped, not fatal.

func StopAll

func StopAll() ([]Record, error)

StopAll signals every live daemon in the global registry (DMON-02, `daemon stop --all`). An empty registry is a clean no-op: (nil, nil), not an error.

func StopMatching

func StopMatching(repoRoot string) ([]Record, error)

StopMatching signals every live daemon in the global registry whose RepoRoot resolves to the same path as repoRoot (DMON-02, `daemon stop -p/--path`). No match is a clean no-op: (nil, nil), not an error.

Jump to

Keyboard shortcuts

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