feat(daemon): connection-file handshake and CLI attach-or-spawn
Summary
Phase 1 of "package flows so it's frictionless": relayflowd currently requires a human to build it and run relayflowd serve by hand before any flows run/flows check/flows resume can connect — the CLI fails closed on a missing socket rather than starting one. This gives relayflowd a connection-file handshake and gives the flows CLI attach-or-spawn logic, ported from ../relay's broker-lifecycle pattern, so flows run x.yaml needs no manual daemon step.
- Kernel (
kernel/relayflowd/src/server/lifecycle.rs):relayflowd serveholds an exclusiveflock(2)on<data-dir>/relayflowd.lockfor its whole lifetime, only sweeps leftover socket/connection-file residue after acquiring that lock, binds, and only then atomically publishes<data-dir>/connection.json(temp-file + rename). Clean shutdown (SIGTERM/SIGINT) unlinks the file and socket from an async-signal-safe handler;kill -9leaves the file behind by construction. A secondserveon an already-served data dir loses the race and exits 3 without touching anything. - SDK:
daemon-connection.tsvalidatesconnection.jsonagainst a live-pid check and a sockethelloprobe (the socket is always the authority;EPERMcorrectly counts as alive).daemon-lifecycle.tsis the attach-or-spawn algorithm — attach if something answers, otherwise resolve the binary (relayflowd-path.ts, multi-anchor resolution) and spawn it detached, polling for readiness. Wired intocheck/run/resumeviacli.ts. New--no-spawn/FLOWS_NO_SPAWN=1restores today's exact fail-closed behavior for CI. docs/SURFACE.md§5 updated — it previously stated "neither verb starts the daemon implicitly," which this PR makes false.- Full spec in
kernel/DAEMON-LIFECYCLE.md; full build/test/review writeup inops/DAEMON-LIFECYCLE-REPORT.md.
This was built and verified as workflows/daemon-lifecycle.yaml, an 8-step DAG (design → kernel impl → kernel tests → CLI impl → SDK tests → e2e smoke → adversarial review → report). The first adversarial-review pass failed: kernel/DAEMON-LIFECYCLE.md §6 specifies 6 kernel tests and only 3 existed. Two were missing — the anti-hijack case (a second serve on a live data dir exits 3, first daemon keeps serving) and the SIGKILL-successor case (a killed daemon's successor starts cleanly). Both are now written against the real relayflowd binary and pass.
Test plan
-
cargo test --workspace(kernel) — all green, including the two new real-process daemon-lifecycle tests -
npm test(packages/sdk) — 803 passed, 3 skipped - e2e smoke: cold-start
flows runagainst an empty data dir with no daemon running spawns exactly one daemon and completes - Adversarial review against concurrent-spawn races, stale-file false-attach, detached-spawn survival, manual-operator backward compatibility, and publish-ordering — all hold up; see
ops/DAEMON-LIFECYCLE-REPORT.md
Not in scope
Phase 2 (per-platform relayflowd/flows binary packages for macOS/Windows, mirroring packages/runtime-linux-x64 which is Linux-only today) is separate follow-up work, noted in the report.
🤖 Generated with Claude Code
https://claude.ai/code/session_013Y8uLRUXqKSZsqeeUMNaS2
Summary by cubic
flows run/check/resume now work without a manually started daemon: the CLI attaches to a relayflowd that's already serving, or spawns one detached and polls until ready, instead of failing closed on a missing socket.
Daemon handshake
relayflowd serveholds an exclusiveflockfor its lifetime; a competingserveon the same data dir exits 3 without touching anything.- Publishes
<data-dir>/connection.jsononly after the socket is live, and removes both on SIGTERM/SIGINT; akill -9leaves the file behind, which the client tolerates. --no-spawn/FLOWS_NO_SPAWN=1restores the previous fail-closed behavior for tooling and CI.
Client validation
- The connection file is never believed alone: it's checked against a live pid and a socket
helloprobe, where the socket is the authority andEPERMcounts as alive. - New failure kinds distinguish spawn failure, start timeout, protocol mismatch, and a missing binary; binary resolution tries multiple anchors so version-manager PATHs don't break.
JournalClientconnects now have a timeout, so a listener that never answers doesn't hold the CLI for the full request timeout.