daemon

package
v0.16.0 Latest Latest
Warning

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

Go to latest
Published: May 18, 2026 License: MIT Imports: 20 Imported by: 0

README

pkg/daemon

Background sync daemon that monitors a provider session transcript (Claude Code transcript or Codex rollout) and uploads it incrementally to the backend. One daemon runs per active session (one per Codex root tree for Codex).

Files

File Role
daemon.go Daemon struct, Run loop, sync cycles, shutdown, inbox I/O, parent monitoring
state.go State persistence (~/.confab/sync/{provider}/{id}.json, with legacy flat-path fallback), process liveness checks, listing. Path builders are thin wrappers over pkg/confabpath.

Lifecycle

spawn ──> waitForTranscript (poll 2s, timeout 60s)
              │
              ▼
         save state file
              │
              ▼
         sync loop ◄──────────────────┐
           │                          │
           ├── tryInit (lazy auth)    │
           ├── SyncAll (engine)       │
           ├── check parent alive     │
           └── sleep(30s ± 5s jitter)─┘
              │
              ▼ (stop signal / parent dead / context cancel)
         shutdown
           ├── read inbox events (SessionEnd payload)
           ├── final sync (with 30s timeout)
           ├── send session_end event
           ├── delete state file
           └── delete inbox file

Key Types

  • Config — Daemon configuration: external ID, transcript path, CWD, parent PID, sync interval/jitter
  • Daemon — Runtime state: engine, stop/done channels, consecutive error counter
  • State — Persisted to disk: external ID, paths, PIDs, start time, backend session ID

How to Extend

Adding daemon behavior during sync: Hook into the sync loop in Run(). New behavior should go after the tryInit() / engine.SyncAll() calls. Follow the existing error handling pattern — log errors, don't crash.

Adding a new inbox event type: Add the type string constant. writeInboxEvent() and readInboxEvents() are generic — they serialize/deserialize InboxEvent structs. Handle the new type in shutdown() where inbox events are processed.

Adding new state fields: Add to the State struct in state.go. The state is JSON-serialized, so new fields are backwards-compatible with omitempty.

Invariants

  • State directory permissions are 0700. ~/.confab/sync/ is created with restrictive permissions since state files may contain session metadata.
  • Signal channel buffer is 2 to avoid dropping signals when both SIGINT and SIGTERM arrive in quick succession.
  • Shutdown goroutine has panic recovery to ensure state file cleanup even if shutdown logic panics.
  • State file must be deleted on exit. If a state file exists with a dead PID, it blocks future daemon spawns until cleanup. The panic recovery handler also deletes the state file.
  • Shutdown must have a timeout (shutdownTimeout, default 30s). The backend may be unresponsive, and the daemon must not hang forever.
  • Parent PID monitoring uses signal(0), not /proc. os.FindProcess + Signal(0) works on both macOS and Linux. /proc is Linux-only.
  • Daemon must be resilient to backend unavailability. Never crash on network errors. Log the error and retry on the next sync interval.
  • Inbox file must be cleaned up on shutdown. Stale inbox files don't cause bugs but are unnecessary clutter.
  • Stop() is idempotent (uses sync.Once). Multiple callers (signal handler, parent monitor, explicit stop) can all call Stop() safely.
  • Consecutive 404 detection. After 3 consecutive 404 errors (maxConsecutiveNotFound), the daemon shuts down — the session was deleted from the backend.
  • Auth recovery. On ErrUnauthorized, the engine is reset to force config re-read on the next cycle. This allows users to fix their API key without restarting the daemon.
  • Codex: one daemon per root tree, not per rollout. The hook handler walks every Codex SessionStart event up to its top-most root before calling maybeSpawnCodexDaemon, so state files are always keyed by root UUID. Subagent rollouts are picked up by the running root daemon's per-cycle tracker.DiscoverCodexDescendants call and uploaded as sidechain files. Subagent SessionStart events for an already-running root tree become no-ops.

Design Decisions

Lazy authentication. The daemon starts immediately when the provider launches a session, but the user may not have authenticated yet. tryInit() defers backend communication until the first sync cycle, and handles auth failures gracefully.

Jittered sync interval. The base interval is 30s with ±5s random jitter. This prevents thundering herd when multiple sessions start simultaneously. The jitter is applied per-cycle, not just at startup.

State files with PID-based liveness check. The state file stores the daemon PID. IsDaemonRunning() sends signal 0 to check if the process is still alive. This is more reliable than lock files (which can be orphaned) and simpler than IPC.

Panic recovery deletes state file. If the daemon panics, the recovery handler logs the panic and deletes the state file. This prevents a corrupt daemon from permanently blocking future spawns. A clean restart is preferred over trying to recover from unknown state.

Inbox file for IPC. The sync stop command needs to pass the SessionEnd hook payload to the running daemon. Rather than building an IPC mechanism (socket, pipe), the stop command appends the event to an inbox JSONL file, then sends SIGTERM. The daemon reads the inbox during shutdown. This is simple and reliable.

Testing

go test ./pkg/daemon/...
Unit tests (daemon_test.go, state_test.go)
  • Stop/cancel behavior, idempotency
  • Inbox write/read/cleanup
  • Auth recovery on 401
  • State CRUD, process checks, listing
Integration tests (integration_test.go)

Full lifecycle tests with mock HTTP backend. Key scenarios:

  • Sync cycle (init + upload)
  • Retry on backend errors
  • Agent discovery (including late-appearing agents)
  • Incremental sync (only new lines)
  • Multiple sync cycles with appended content
  • Late-appearing transcript file
  • Shutdown with final sync
  • Concurrent startup protection
  • File truncation resilience
  • Large files and chunk size limits

Override shutdownTimeout (package var) in tests for fast execution. Use CONFAB_CLAUDE_DIR to isolate test directories.

Dependencies

Uses: pkg/sync, pkg/config, pkg/confabpath, pkg/http, pkg/types, pkg/logger

Used by: cmd/ (spawn, sync start/stop, status)

Documentation

Index

Constants

View Source
const (
	// DefaultSyncInterval is the base interval for syncing files
	DefaultSyncInterval = 30 * time.Second
)

Variables

This section is empty.

Functions

func GetInboxPathForProvider added in v0.16.0

func GetInboxPathForProvider(provider, externalID string) (string, error)

GetInboxPathForProvider returns the namespaced inbox file path.

func GetStatePathForProvider added in v0.16.0

func GetStatePathForProvider(provider, externalID string) (string, error)

GetStatePathForProvider returns the namespaced state file path.

func GetSyncDir

func GetSyncDir() (string, error)

GetSyncDir returns the path to the sync state directory

func StopDaemon

func StopDaemon(externalID string, hookInput *types.ClaudeHookInput) error

StopDaemon sends SIGTERM to a running daemon by external ID. If hookInput is provided, it writes a session_end event to the daemon's inbox before signaling, so the daemon can access the full SessionEnd payload.

func StopDaemonForProvider added in v0.16.0

func StopDaemonForProvider(providerName, externalID string, hookInput *types.ClaudeHookInput) error

StopDaemonForProvider sends SIGTERM to a running daemon by provider and external ID.

Types

type Config

type Config struct {
	Provider           string
	ExternalID         string
	TranscriptPath     string
	CWD                string
	ParentPID          int // Claude Code process ID to monitor (0 to disable)
	SyncInterval       time.Duration
	SyncIntervalJitter time.Duration // 0 to disable jitter (for testing)
}

Config holds daemon configuration

type Daemon

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

Daemon is the background sync process.

The daemon is resilient to backend unavailability - it will keep running and retry connecting to the backend on each sync interval. Once connected, it will sync any accumulated changes.

If ParentPID is set, the daemon monitors the parent process and shuts down gracefully when it exits. This handles cases where Claude Code crashes or is killed without firing the SessionEnd hook.

func New

func New(cfg Config) *Daemon

New creates a new daemon instance

func (*Daemon) Run

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

Run starts the daemon and blocks until stopped

func (*Daemon) Stop

func (d *Daemon) Stop()

Stop signals the daemon to stop. Safe to call multiple times.

type State

type State struct {
	Provider        string    `json:"provider,omitempty"`
	ExternalID      string    `json:"external_id"`
	TranscriptPath  string    `json:"transcript_path"`
	CWD             string    `json:"cwd"`
	PID             int       `json:"pid"`
	ParentPID       int       `json:"parent_pid,omitempty"` // Claude Code process ID
	InboxPath       string    `json:"inbox_path"`           // Path to event inbox (JSONL)
	StartedAt       time.Time `json:"started_at"`
	ConfabSessionID string    `json:"confab_session_id,omitempty"` // Backend session ID (set after Init)
}

State represents the daemon's persistent state

func ListAllStates

func ListAllStates() ([]*State, error)

ListAllStates returns all active sync states

func LoadStateForProvider added in v0.16.0

func LoadStateForProvider(provider, externalID string) (*State, error)

LoadStateForProvider reads a provider-namespaced state file. Claude Code falls back to the legacy flat path so old daemons and existing hooks keep working.

func NewStateForProvider added in v0.16.0

func NewStateForProvider(provider, externalID, transcriptPath, cwd string, parentPID int) *State

NewStateForProvider creates a daemon state under a provider namespace.

func (*State) Delete

func (s *State) Delete() error

Delete removes the state file from disk

func (*State) IsDaemonRunning

func (s *State) IsDaemonRunning() bool

IsDaemonRunning checks if the daemon process is still alive

func (*State) IsParentRunning

func (s *State) IsParentRunning() bool

IsParentRunning checks if the parent Claude Code process is still alive

func (*State) Save

func (s *State) Save() error

Save writes the state to disk

Jump to

Keyboard shortcuts

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