Skip to content

vcli-daemon: long-running process that wires IPC + store + runtime (plan 4) - #10

Merged
blaksmatic merged 25 commits into
masterfrom
implement/plan-4-vcli-daemon
Apr 21, 2026
Merged

blaksmatic merged 25 commits into
masterfrom
implement/plan-4-vcli-daemon

Conversation

@blaksmatic

Copy link
Copy Markdown
Owner

Summary

Ships the vcli-daemon binary — a long-running process that wires vcli-ipc (transport), vcli-store (SQLite persistence), and vcli-runtime (scheduler) into a single coordinating daemon that clients talk to over a Unix-domain socket.

Architecture

  • Threading: tokio reactor on main thread; scheduler pinned to std::thread::spawn; crossbeam_channel bridges tokio↔scheduler; tokio::sync::broadcast fans events out to IPC subscribers.
  • Ordering invariant (Decision 1.7): events persisted to the DB via Store::append_event before broadcast to subscribers.
  • Observable readiness (Decision 1.2): socket bound last so its absence implies "not ready"; unlinked on shutdown.
  • PidFile: fs2 advisory flock; kernel releases the lock on fd close (correctly handles SIGKILL).
  • Error mapping: DaemonError::code() → vcli_core::ErrorCode (InvalidProgram / Internal).
  • Stream history vs follow: follow=false drains persisted events and returns; follow=true subscribes to broadcast and emits stream.dropped frames on RecvError::Lagged.

What's in this PR

17 plan tasks across 25 commits, per docs/superpowers/plans/2026-04-17-vcli-daemon.md. Highlights:

  • DaemonError + platform path resolution
  • PidFile with fs2 advisory lock
  • Tracing subscriber with daily rotation (JSON)
  • CommandChannel tokio↔scheduler bridge
  • EventSink persist-then-broadcast
  • Full DaemonHandler (Health, Submit, List, Status, Cancel, Start, Resume, Gc, Shutdown, streaming Logs/Events/Trace)
  • Startup orphan-recovery + waiting-reload
  • Signal handlers (SIGINT/SIGTERM) + graceful shutdown
  • Binary main with runtime factory
  • Integration tests: orphan recovery, submit over IPC, graceful shutdown cleanup

Test Plan

  • cargo test --workspace --locked green on macOS
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt --all -- --check clean
  • #![forbid(unsafe_code)] at crate root
  • Reviewer: pull and run the gate locally

🤖 Generated with Claude Code

blaksmatic and others added 25 commits April 20, 2026 15:30
Wall-clock ms convenience for daemon callers that do not hold a Clock
handle. Equivalent to SystemClock::new().unix_ms(). Re-exported from the
crate root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ordered by submitted_at ASC then rowid for stable output. Unit tests
cover both the no-filter and filtered paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
i64::try_from(i).unwrap() for the usize→i64 cast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds crates/vcli-daemon as a workspace member, a lib + placeholder bin,
and the workspace dependencies (tracing-subscriber, tracing-appender,
fs2, dirs, signal-hook-tokio, futures-util) the later tasks consume.
Also extends tokio feature set with `signal`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
thiserror-based enum covering startup, pidfile, paths, logging, store,
ipc, io, and invalid-program branches. `code()` maps each to a stable
vcli_core::ErrorCode (InvalidProgram → InvalidProgram, rest → Internal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
from_platform_defaults() resolves data_root / log_dir per platform
(Library on macOS, XDG on Linux) and socket via vcli_ipc. with_roots()
gives tests and alt installs explicit overrides. ensure_dirs()
idempotently mkdir -p's data_root and log_dir.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PidFile::acquire opens <path>, takes fs2 exclusive lock, writes current
PID. Collision returns DaemonError::AlreadyRunning with the owning PID
read from the file. release() consumes self, unlocks + unlinks; Drop
covers the panic / fast-exit path the same way.

Stale PIDs after SIGKILL are handled automatically: the kernel releases
the flock on fd close, so the next daemon acquires cleanly without
needing a kill-check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
logging::init(log_dir) installs a JSON tracing subscriber whose file
writer is tracing-appender's daily roller (`daemon.log.YYYY-MM-DD`).
Respects RUST_LOG via EnvFilter; defaults to info. Returns a LogGuard
the caller must keep alive to avoid losing buffered records.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
new_channels() returns a bundle of endpoints: a bounded crossbeam
cmd_tx for tokio→scheduler commands, an unbounded event_rx/sched_event_tx
pair for scheduler→tokio events, and a tokio broadcast::Sender
(capacity 1024) for fanning persisted events to IPC subscribers.

Also adds build.rs with the same macOS libswift_Concurrency rpath
vcli-runtime uses — required because vcli-daemon pulls in vcli-capture
transitively.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
spawn_event_pump drains the scheduler's crossbeam Receiver<Event> on a
tokio spawn_blocking task. For program-scoped events it calls
Store::append_event before broadcasting; daemon-scoped events
(DaemonStarted/Stopped/Pressure/StreamDropped/TickFrameSkipped/
CapturePermissionMissing) bypass the DB and broadcast only
(Decision 1.7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DaemonHandler carries Arc<Mutex<Store>>, CommandChannel, started_at
Instant, and an oneshot shutdown_tx. handle() dispatches to
handle_health / handle_shutdown; every other op returns Internal
"not yet wired" until the follow-up 8b–8g subtasks land each op.

Tests live on a test_support::fresh_handler() fixture that keeps the
tempdir alive and exposes the bridge's cmd_rx for later subtasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handle_submit runs vcli_dsl::validate_value on the incoming JSON. On
success it canonicalizes and writes a Pending row via spawn_blocking,
then pushes SchedulerCommand::SubmitValidated into cmd_tx. DslError
already exposes to_payload(), so error paths map straight to the wire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handle_list parses the optional state filter, queries
Store::list_programs off the async executor via spawn_blocking, and
emits {items: [...]}. handle_status returns the single-row view or
maps StoreError::UnknownProgram to ErrorCode::UnknownProgram.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both ops push the matching SchedulerCommand onto cmd_tx and surface a
DaemonBusy error if the bounded queue is full. Scheduler owns the
state transition; the handler is a pure forwarder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handle_resume runs Store::resume_program (DB state transition) then
re-reads and re-validates the source_json via vcli_dsl so we hand the
scheduler a fresh vcli_core::Program. StoreError::NotResumable and
UnknownProgram are mapped to matching ErrorCode variants so clients
can distinguish "wrong state" from "missing id" cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
stream_events handles both firehose and per-program filtering plus
follow-mode broadcast receives with lag-recovery via a stream.dropped
frame. Non-follow mode pages the stored history out of SQLite and
returns. Trace is a stubbed no-op pending runtime trace support.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handle_gc computes a retention cutoff (now - RETENTION_DAYS) on the
async executor, calls Store::gc_all via spawn_blocking, and surfaces
the GcReport fields (programs/assets/blobs/orphan_blobs deleted).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
emit_recovery_events writes (program.state_changed, program.failed)
pairs for every RecoveredProgram returned by Store::open — so IPC
subscribers see a clean transition from running into
failed(daemon_restart). emit_daemon_started posts the version banner.
reload_waiting_programs walks rows in Waiting state and pushes each
through SchedulerCommand::SubmitValidated so triggers are re-armed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
install_signal_handlers spawns a signal-hook-tokio task that watches
SIGTERM + SIGINT and fires the shared shutdown oneshot. shutdown_tx is
an Arc<Mutex<Option<…>>> shared with DaemonHandler so either the OS or
an IPC Shutdown request wins — whichever races first. emit_daemon_stopped
posts the terminal event on the scheduler→pump channel so the DB and
every subscriber see the shutdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Orchestrates the full startup sequence in the order spec §Restart
semantics Phase A requires: ensure_dirs → logging → pidfile → open
store (restart recovery) → channels → event pump → recovery events
→ daemon.started → reload waiting programs → spawn scheduler thread
→ build handler + shutdown oneshot → signal handlers → bind socket
LAST (Decision 1.2, observable readiness). Shutdown path drains the
scheduler, emits daemon.stopped, closes the pump, and releases the
pidfile.

The RuntimeFactory closure seam lets tests inject MockCapture and
MockInputSink; the binary wires the real macOS backends in Task 12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the placeholder main with --version / --help plus the real
run_foreground entrypoint. Builds a multi-thread tokio runtime,
resolves platform defaults into a Config, and hands run_foreground the
default_runtime_factory — which, for v0, returns MockCapture +
MockInputSink + default Perception + SystemRuntimeClock. Real macOS
backends land in a future lane.

Exit code policy: 0 on clean shutdown, 1 on any DaemonError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plants a Running row, starts the daemon, waits for the socket to
appear, then sends Shutdown over IPC. After the daemon drains cleanly,
reopens the store and asserts both the row state transitioned to
Failed and the event log contains program.state_changed and
program.failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end: starts the daemon with mock backends, connects via
IpcClient, sends Submit with a minimal valid program, and asserts the
response carries a program_id. Then issues Shutdown so the daemon
drains cleanly inside the test timeout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Starts the daemon, connects, issues Shutdown, and after the daemon
drains asserts both the socket file and the pidfile are unlinked —
observable readiness in reverse (Decision 1.2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical rustfmt reflow of config.rs, handler.rs, and
graceful_shutdown.rs (no behavior changes). Also commits the Cargo.lock
update from adding libc to vcli-daemon's deps in the shutdown lane
commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@blaksmatic
blaksmatic merged commit e84ebf0 into master Apr 21, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant