supervisor#

import "github.com/jbcom/radioactive-ralph/internal/supervisor"

Package supervisor implements the `–supervisor` process: the single durable authority described in docs/superpowers/specs/2026-07-16-supervisor-architecture-design.md §4-§6. It owns the one user-level store, all agent ptys, and the IPC endpoint clients discover (§5c: “the socket is the advertisement”). Single-instance is enforced by an exclusive flock on the PID file, not by the socket bind (which happens downstream of that lock).

Index#

Variables#

ErrNoSupervisor is returned by Find when no live supervisor answers a connect at runtimeDir’s socket — either nothing is listening, the socket file is missing, or it is a stale leftover from a crashed process.

var ErrNoSupervisor = errors.New("supervisor: no supervisor is listening")

ErrSupervisorRunning is returned by Acquire when another live supervisor already holds the lock. The actual single-instance mutex is the exclusive non-blocking flock on the PID file (acquirePIDLock); the socket advertisement (§5c) that clients discover is bound downstream of that lock (inside ipc.Server.Start). This error surfaces the second acquire’s failure with a name callers can match via errors.Is.

var ErrSupervisorRunning = errors.New("supervisor: another supervisor is already running")

func Find#

func Find(runtimeDir string) (*ipc.Client, error)

Find tries to connect to the supervisor socket under runtimeDir. A successful connect means a live supervisor answered — the returned *ipc.Client is ready to use. Any failure (connect refused, socket missing, or a stale socket nothing is listening behind) collapses to ErrNoSupervisor: callers don’t need to distinguish “never started” from “crashed,” both mean the client should offer to start one (spec §4).

func Run#

func Run(ctx context.Context, opts Options) error

Run acquires the supervisor socket (failing with ErrSupervisorRunning if another instance already holds it), registers a supervisor session in the store, serves IPC until ctx is cancelled or a client sends CmdStop, and then shuts down cleanly: any in-flight dispatch is bounded by orch’s own watchdog config, the IPC server is stopped, the socket released, the session closed.

type Listener#

Listener wraps the bound supervisor socket plus the resources that enforce single-instance: the *ipc.Server built on top of it and the PID lockfile held for this process’s lifetime. Release must be called exactly once, typically via a deferred call from the owning Supervisor.

type Listener struct {
    SocketPath    string
    HeartbeatPath string
    // contains filtered or unexported fields
}

func Acquire#

func Acquire(runtimeDir string) (*Listener, error)

Acquire takes the single-instance lock for runtimeDir. The actual mutex is the exclusive non-blocking flock on the PID file (acquirePIDLock): a second live supervisor fails to take that lock and Acquire returns ErrSupervisorRunning. The socket clients discover (spec §5c: “the socket is the advertisement”) is bound later, in ipc.Server.Start, strictly after Acquire has already won the PID lock — so two processes racing Acquire contend on the flock, never on the socket bind.

Before taking the lock, Acquire checks whether the socket path is a stale leftover from a crashed supervisor: if a live client can still connect, a supervisor is genuinely running (ErrSupervisorRunning). If nothing answers, Acquire consults the PID lockfile — a dead recorded PID means the previous supervisor crashed without cleaning up, so Acquire reclaims: removes the stale socket file and takes over the PID lock itself. A missing or already-unlocked PID file is treated the same as a dead PID (nothing to protect the reclaim from).

func (*Listener) Release#

func (l *Listener) Release() error

Release closes the PID lockfile (dropping the flock) and removes the PID file. It does NOT close the *ipc.Server bound to SocketPath — the caller owns that server’s lifecycle separately (Server.Stop() also unlinks the socket file). Calling Release after the server has stopped is the expected order: server down, then mutex released.

type Options#

Options configures a Supervisor run.

type Options struct {
    // RuntimeDir is the XDG-level directory the supervisor socket,
    // heartbeat, and PID lock live under (spec §5c). Working directory is
    // irrelevant to the supervisor (spec §4) — this is the one path that
    // matters.
    RuntimeDir string

    // Store is the already-open user-level database (spec §6). Supervisor
    // does not open it itself so callers can inject a test double or a
    // store opened with a fake clock.
    Store *store.Store

    // Orchestrator dispatches plan/task work (Phase 6). Optional: nil
    // defaults to orch.New(Store) — a real provider.NewRunner-backed
    // orchestrator. Tests inject one built with a fake RunnerFactory so
    // HandleEnqueue's dispatch wiring can be proven without a real
    // provider CLI.
    Orchestrator *orch.Orchestrator

    // Logger receives lifecycle messages. Optional.
    Logger func(msg string, args ...any)
}

type Supervisor#

Supervisor is the small, boring control-plane process described in spec §4/§13: pty ownership + IPC + store + reaper, PLUS (as of Phase 6c) real plan dispatch: HandleEnqueue drives internal/orch’s DispatchNext instead of returning “not implemented”. Orch itself — via the provider runners it dispatches onto internal/agent — owns every agent subprocess’s lifetime (start, watchdog supervision, kill), so the supervisor holds no separate pty-tracking map of its own; there is nothing left for the supervisor to additionally track or drain at shutdown.

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

func (*Supervisor) HandleAttach#

func (s *Supervisor) HandleAttach(ctx context.Context, args ipc.AttachArgs, emit func(json.RawMessage) error) error

HandleAttach streams the project’s events to the client as they are written, turning the observe half of the drive+observe API from a stub into a live feed. It TAILS the append-only events table: each tick it reads rows with id greater than the cursor (scoped to args.ProjectID, including plan-linked rows), emits each, and advances the cursor. The cursor starts at args.AfterID — the client owns it (it obtains an initial value from MaxEventID/backlog), so there is no server-side seed and no lost-event race. The loop returns when ctx is cancelled (client disconnect — #165’s watcher — or supervisor shutdown) or when emit reports the client is gone.

func (*Supervisor) HandleEnqueue#

func (s *Supervisor) HandleEnqueue(ctx context.Context, args ipc.EnqueueArgs) (ipc.EnqueueReply, error)

HandleEnqueue drives one real dispatch pass via internal/orch instead of returning “not implemented”: it lists every currently active/paused plan store-wide (spec: the supervisor is project-agnostic — it has no notion of “the current project”, so this checks every project’s active work, not just one) and calls DispatchNext on each, in plan order, until either every plan has been tried or maxEnqueueDispatchPlans is reached (a bound so one enqueue call can never scan an unbounded number of plans). It is NOT a blocking wait for any of that work to finish — DispatchNext itself is synchronous per dispatched step but bounded by orch’s own watchdog config (agent.WatchdogConfig.StallTimeout), so a stalled/prompting provider still returns (killed) rather than hanging this IPC call.

args.Description/args.TaskID name the work the caller wanted enqueued, but a store task cannot be created without a plan_id (tasks.plan_id is a NOT NULL foreign key) and EnqueueArgs carries no plan reference — so HandleEnqueue’s job today is exactly “wake up dispatch for whatever is already ready”, the same effect an enqueue is meant to have (make already-known work actually run), not “materialize a new ad hoc task with no plan to belong to”. EnqueueReply.Inserted reports whether anything was actually dispatched; TaskID echoes args.TaskID (or, if unset, the number of steps dispatched, best-effort) so a caller has some return value acknowledging its enqueue signal was acted upon.

func (*Supervisor) HandlePlanImport#

func (s *Supervisor) HandlePlanImport(ctx context.Context, args ipc.PlanImportArgs) (ipc.PlanImportReply, error)

HandlePlanImport creates a plan from markdown and activates it — the same logic the `plan import` CLI runs, moved server-side.

func (*Supervisor) HandlePlanSetStatus#

func (s *Supervisor) HandlePlanSetStatus(ctx context.Context, args ipc.PlanSetStatusArgs) (ipc.PlanSetStatusReply, error)

HandlePlanSetStatus changes a plan’s lifecycle status, validated to the allowed operator transitions.

func (*Supervisor) HandleReloadConfig#

func (s *Supervisor) HandleReloadConfig(_ context.Context) error

HandleReloadConfig is a no-op today: config reload semantics belong to vconfig’s virtual-layer resolution (spec §5a), which this minimal supervisor does not yet wire into a running process’s live config.

func (*Supervisor) HandleStatus#

func (s *Supervisor) HandleStatus(ctx context.Context) (ipc.StatusReply, error)

HandleStatus reports supervisor-level liveness. ActiveWorkers and the per-worker detail are sourced from the store’s real worker rows (store.ListRunningWorkers) rather than an in-process map: no in-process structure could ever reflect this anyway, since agent subprocess lifetime is fully owned by whichever provider runner orch dispatched, not by the supervisor itself. A query failure degrades to an empty list / 0 count rather than failing the whole status reply — a transient error should never make `status` itself fail.

func (*Supervisor) HandleStop#

func (s *Supervisor) HandleStop(_ context.Context, _ ipc.StopArgs) error

HandleStop breaks Run’s select loop, which triggers shutdown. Graceful vs. immediate is not yet differentiated (no in-flight plan work exists yet to wait on) — both simply request shutdown.

func (*Supervisor) HandleTaskApprove#

func (s *Supervisor) HandleTaskApprove(ctx context.Context, args ipc.TaskApproveArgs) error

HandleTaskApprove clears the approval gate on a ready_pending_approval task.

func (*Supervisor) HandleWorkerKill#

func (s *Supervisor) HandleWorkerKill(ctx context.Context, args ipc.WorkerKillArgs) error

HandleWorkerKill cancels the worker’s live provider subprocess and then reclaims its task and terminates the worker row. The process cancellation (orch.KillWorker) aborts the in-flight runner.Run context so the subprocess tears down at once rather than running on until its own timeout; the store reclaim (kill-and-reclaim, the same shape the reaper uses) requeues the task(s) and marks the worker terminated.

Generated by gomarkdoc