Skip to content

feat(daemon): zero daemon — worker pool + session supervisor over a local control socket#203

Merged
gnanam1990 merged 2 commits into
mainfrom
feat/daemon
Jun 14, 2026
Merged

feat(daemon): zero daemon — worker pool + session supervisor over a local control socket#203
gnanam1990 merged 2 commits into
mainfrom
feat/daemon

Conversation

@gnanam1990

@gnanam1990 gnanam1990 commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds internal/daemon + the zero daemon start|stop|status|run|attach subcommands: a long-running local service that supervises a pool of headless zero exec workers and routes multiple agent sessions to them over an owner-only local Unix-domain control socket. Fully additive — interactive and one-shot exec are unchanged.

Reuses zero's building blocks

zero code how
internal/background ConfigureChildProcessGroup (process-group setup) + TerminateProcess (cross-platform terminate) for worker kill/drain. The pool does its own os/exec and uses background only for these helpers.
internal/streamjson workers speak stream-json on stdout; the daemon forwards those lines to clients.
internal/cli exec a worker is zero exec --output-format stream-json.

Pieces (reimplemented in Go from a reference daemon)

File Mirrors Behavior
protocol.go protocol.js framed control codec: 4-byte BE len + 1 kind byte, 1 MiB cap, JSON control messages, version negotiation
pool.go + backoff.go worker-manager.js bounded pool; at-least-once dispatch with exp backoff+jitter retry capped by MaxAttempts; EXIT_TEMPFAIL(75)/EXIT_PERMANENT(76); graceful drain + kill-timeout
session.go session-manager.js / lease.js / roster.js sessionID→worker routing, lease (one active request/worker), queue when full, per-session attach buffer + metrics
server.go + lock.go + socket.go + paths.go supervisor.js / status.js single-instance PID-file lock + stale-lock (dead-PID) recovery, status file, owner-only socket, accept loop, cleanup on exit
launcher.go / client.go spawner.js / attach.js production worker launcher; control-socket client

Design note: zero's exec is one-shot (one prompt → run → exit), so workers are on-demand (one session per worker — the lease is inherent), not warm long-lived slots. Warm/pre-spawned persistent workers would need a persistent worker mode in exec → follow-up.

Security model

  • Local-only: AF_UNIX socket file on the per-user runtime dir — never a TCP port.
  • Owner-only: socket 0600 + parent dir 0700 on POSIX (verified live: srw------- / drwx------). On Windows the per-user profile dir ACL applies; a dedicated pipe ACL is a follow-up.
  • Workers stay sandboxed: the launcher scrubs the sandbox re-entrancy markers (ZERO_SANDBOXED / ZERO_SANDBOX_BACKEND) from each worker's env so the worker re-establishes its own sandbox instead of inheriting a pass-through plan, while propagating the rest of the policy config/env. The daemon never bypasses the sandbox.
  • Control frames over the 1 MiB cap are rejected before allocation.

Robustness

A crashed worker never takes down the daemon (backoff+jitter retry, MaxAttempts cap, EXIT_PERMANENT stops retry). Drain gives a grace window then force-kills stragglers. A stale lock from an unclean exit is reclaimed (dead-PID check, x/sys on Windows).

Out of scope (follow-up)

remote session-linking; warm pre-spawned persistent workers; Windows named-pipe + DACL.

Tests

protocol round-trip + oversize/unknown-kind rejection; backoff bounds; pool spawn/retry-backoff/permanent/cap/queue/drain; session lease/route/queue/attach; lock single-instance + stale recovery; launcher env-scrub end-to-end (asserts the spawned worker process actually receives scrubbed env); server e2e start→run→stream-json→status→attach→stop with a fake worker; CLI usage/validation/not-running. Live-verified start/status/stop.

Gates

gofmt -l . empty · go vet · go build · go test ./... (incl. -race on internal/daemon) · staticcheck no new findings · govulncheck 0 · deadcode -test=false no new unreachable funcs · platform files behind explicit //go:build tags · cross-compiled GOOS=linux and GOOS=windows.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added daemon mode to the CLI with start, stop, status, run, and attach subcommands.
    • Supports foreground and detached daemon operation, including background logging.
    • Session-based execution with streamed output and the ability to attach to existing sessions.
    • Daemon status reporting for process/socket info plus worker and session metrics.
    • Enforces single-instance daemon behavior and improves control-socket hardening.
  • Tests

    • Added comprehensive unit and end-to-end test coverage for the daemon CLI, server, protocol, sessions, and worker pool.

…er a local control socket

Adds internal/daemon + `zero daemon start|stop|status|run|attach`: a long-running
local service that supervises a pool of headless `zero exec` workers and routes
multiple agent sessions to them over an owner-only Unix-domain control socket.
Additive — interactive and one-shot exec are unchanged.

Reuses zero's building blocks:
- internal/background: ConfigureChildProcessGroup (process-group setup) +
  TerminateProcess (cross-platform terminate) for worker kill/drain. The pool
  does its own os/exec and uses background only for those helpers.
- internal/streamjson: the worker speaks stream-json on stdout; the daemon
  forwards those lines to clients.
- internal/cli exec: a worker is `zero exec --output-format stream-json`.

Pieces (reimplemented in Go from a reference daemon):
- protocol.go: framed control codec — 4-byte BE length + 1 kind byte, 1 MiB cap,
  JSON control messages, version negotiation.
- pool.go + backoff.go: bounded worker pool — at-least-once dispatch with
  exponential backoff+jitter retry capped by MaxAttempts, EXIT_TEMPFAIL(75)/
  EXIT_PERMANENT(76) handling, graceful drain + kill-timeout. zero's exec is
  one-shot, so workers are on-demand (one session per worker = the lease), not
  warm long-lived slots (a persistent worker mode is a follow-up).
- session.go: SessionManager — sessionID->worker routing, lease (one active
  request per worker), queue when the pool is full, per-session output buffer for
  attach + metrics.
- server.go + lock.go + socket.go + paths.go: single-instance PID-file lock with
  stale-lock (dead-PID) recovery, status file, owner-only socket, accept loop;
  cleans up lock/socket/status on exit.
- launcher.go: production worker launcher. client.go: control-socket client.

Security model:
- Control socket is LOCAL-ONLY (AF_UNIX loopback file, never a TCP port) and
  owner-only: socket 0600 + parent dir 0700 on POSIX (Windows relies on the
  per-user profile ACL; a dedicated pipe ACL is a follow-up).
- Workers stay sandboxed: the launcher SCRUBS the sandbox re-entrancy markers
  (ZERO_SANDBOXED / ZERO_SANDBOX_BACKEND) from each worker's env so the worker
  re-establishes its OWN sandbox instead of inheriting a pass-through plan, while
  propagating the rest of the policy config/env. The daemon never bypasses the
  sandbox.
- Control frames over the 1 MiB cap are rejected before allocation.

Robustness: a crashed worker never takes down the daemon (backoff+jitter retry,
MaxAttempts cap, EXIT_PERMANENT stops retry); drain gives a grace window then
force-kills stragglers; a stale lock from an unclean exit is reclaimed.

Out of scope (follow-up): remote session-linking; warm pre-spawned persistent
workers; Windows named-pipe ACL.

Tests: protocol round-trip + oversize/unknown-kind rejection; backoff bounds;
pool spawn/retry-backoff/permanent/cap/queue/drain; session lease/route/queue/
attach; lock single-instance + stale recovery; launcher env-scrub (incl. an
end-to-end check that the spawned worker process receives scrubbed env); server
e2e start->run->stream-json->status->attach->stop with a fake worker; CLI
usage/validation/not-running paths. Live-verified start/status/stop (0600 socket,
0700 dir, file cleanup). Platform files behind explicit //go:build tags.
@github-actions

github-actions Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 5efd12ccaea8
Changed files (25): internal/cli/app.go, internal/cli/daemon.go, internal/cli/daemon_test.go, internal/daemon/backoff.go, internal/daemon/backoff_test.go, internal/daemon/client.go, internal/daemon/launcher.go, internal/daemon/launcher_test.go, internal/daemon/lock.go, internal/daemon/lock_posix.go, internal/daemon/lock_test.go, internal/daemon/lock_windows.go, and 13 more

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1cf194de-1b0c-4351-81da-c62f8ceb74fb

📥 Commits

Reviewing files that changed from the base of the PR and between 5234426 and 5efd12c.

📒 Files selected for processing (6)
  • internal/cli/app.go
  • internal/cli/daemon.go
  • internal/cli/daemon_test.go
  • internal/daemon/lock.go
  • internal/daemon/session.go
  • internal/daemon/session_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/cli/daemon_test.go
  • internal/cli/daemon.go

Walkthrough

This PR introduces a complete background daemon subsystem for the zero CLI. It adds a framed binary wire protocol, single-instance PID locking, exponential backoff with jitter, a semaphore-based worker pool with retry and drain, a session fan-out model with subscriber channels, an exec-based worker launcher with sandbox environment scrubbing, a Unix-socket server, a control client, and a zero daemon CLI command with start/stop/status/run/attach subcommands.

Changes

zero daemon subsystem

Layer / File(s) Summary
Wire protocol: framing, control messages, version negotiation, status types
internal/daemon/protocol.go, internal/daemon/protocol_test.go, internal/daemon/status.go
Defines 5-byte framed wire format with 1 MiB cap, KindData/KindCtrl frame types, all CtrlType constants, the Ctrl JSON envelope, WriteFrame/ReadFrame, WriteControl/ReadControl, NegotiateVersion, and StatusReport/WorkerStatus/SessionStatus/StatusFile structs with JSON tags.
Daemon runtime primitives: paths, socket hardening, backoff, single-instance lock
internal/daemon/paths.go, internal/daemon/socket.go, internal/daemon/socket_posix.go, internal/daemon/socket_windows.go, internal/daemon/backoff.go, internal/daemon/backoff_test.go, internal/daemon/lock.go, internal/daemon/lock_posix.go, internal/daemon/lock_windows.go, internal/daemon/lock_test.go
Adds XDG-aware DefaultDir/DefaultPaths, socket path-length validation, 0700 parent dir and 0600 file hardening (POSIX/Windows), exponential-with-jitter BackoffWithJitter, and PID-file single-instance locking with platform-specific osProcessAlive and stale-lock recovery.
Worker pool: concurrency, retry, drain
internal/daemon/pool.go, internal/daemon/pool_test.go
Implements Pool with semaphore slot leasing, WorkerHandle/Lines/Launcher/Sink interfaces, PoolOptions with configurable size/attempts/backoff/tempfail delay, Run with exit-code classification (success/permanent/tempfail/retry), runOnce, and idempotent Drain with force-kill after KillTimeout.
Session lifecycle and fan-out output
internal/daemon/session.go, internal/daemon/session_test.go
Defines SessionState (queued/running/done/failed), Session with bounded output ring buffer and per-subscriber broadcast channels, Subscribe returning history+live+cancel, finish for terminal state, and SessionManager routing sessions to pool asynchronously with duplicate-ID rejection and FIFO pruning of finished sessions past MaxSessions.
Exec worker launcher: subprocess spawn, env scrubbing
internal/daemon/launcher.go, internal/daemon/launcher_test.go
Adds ExecLauncherConfig and NewExecLauncher spawning zero exec subprocesses in their own process group with stream-json output, scrubWorkerEnv removing sandbox re-entrancy markers, and execWorker wrapping exec.Cmd with buffered stdout scanning at MaxFrameSize.
Daemon server: Unix socket, single-instance lock, accept loop, command dispatch
internal/daemon/server.go, internal/daemon/server_test.go
Implements Server acquiring the single-instance lock, binding an owner-only Unix socket, writing a status file, running a concurrent accept loop with per-connection goroutines, performing CtrlHello/CtrlHelloOK handshake, dispatching CtrlRun/CtrlAttach/CtrlStatus/CtrlShutdown, and streaming session output as CtrlData frames until CtrlEnd.
Daemon client: Dial, handshake, RPC methods
internal/daemon/client.go
Implements Client with Dial (timeout connect + handshake), Run/Attach (send command + streamLoop), Status (validate CtrlStatusResult), and Shutdown.
CLI wiring: daemon subcommand dispatch and all subcommands
internal/cli/app.go, internal/cli/daemon.go, internal/cli/daemon_test.go
Routes daemon in top-level dispatcher and updates help text, implements runDaemon dispatcher, runDaemonForeground building all components with SIGINT/SIGTERM shutdown, runDaemonStartDetached spawning detached child with 5s socket-reachability polling, and stop/status/run/attach subcommands with argument validation and daemon client calls; CLI tests cover usage, unknown subcommands, missing args, no-daemon error paths, and extra-args rejection.

Sequence Diagram(s)

sequenceDiagram
  actor User
  participant CLI as zero daemon run
  participant Server as Daemon Server
  participant SessionMgr as SessionManager
  participant Pool as Worker Pool
  participant Worker as zero exec

  User->>CLI: zero daemon run --session s1 --prompt "..."
  CLI->>Server: Dial Unix socket
  CLI->>Server: CtrlHello (version)
  Server->>CLI: CtrlHelloOK (negotiated)
  CLI->>Server: CtrlRun (session, cwd, prompt, args)
  Server->>SessionMgr: Start(WorkerSpec)
  SessionMgr->>Pool: Run(ctx, spec, sink)
  Pool->>Worker: launch subprocess
  Worker-->>Pool: stdout lines (stream-json)
  Pool-->>SessionMgr: Line via Sink
  SessionMgr-->>Server: live channel
  Server->>CLI: CtrlData (line)
  Worker-->>Pool: exit 0
  Pool-->>SessionMgr: finish(done, 0, nil)
  Server->>CLI: CtrlEnd
  CLI->>User: print output
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • Gitlawb/zero#158: Modifies internal/cli/app.go's command dispatcher to wire new top-level subcommands using the same dispatch pattern this PR uses to add the daemon case.

Suggested reviewers

  • anandh8x
  • Vasanthdev2004
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.21% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main feature: a daemon implementation with a worker pool and session supervisor communicating via a local control socket.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/daemon

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
internal/daemon/session.go (1)

178-225: 💤 Low value

Sessions accumulate indefinitely in the sessions map.

Finished sessions are never removed from m.sessions. For a long-running daemon handling many sessions, this unbounded growth could become a memory concern. Consider adding a pruning mechanism (TTL, max-sessions cap, or explicit cleanup) if the daemon is expected to handle a high session volume over extended uptime.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/daemon/session.go` around lines 178 - 225, The SessionManager
accumulates finished sessions indefinitely in the sessions map because completed
sessions are never removed. In the Start method, after sess.finish(code, err) is
called in the background goroutine, add cleanup logic to remove the session from
m.sessions map. Lock m.mu before removing the session from the map to avoid
concurrent access issues. This ensures memory is properly released when sessions
complete, preventing unbounded growth in long-running daemons.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/cli/app.go`:
- Around line 243-244: The writeHelp function does not include the newly added
"daemon" command in its help output, making it undiscoverable to users. Locate
the writeHelp function and add "daemon" to the list of commands that are
displayed when users run the help command, ensuring it is formatted consistently
with the other command entries already present in the help text.

In `@internal/cli/daemon.go`:
- Around line 160-163: The functions runDaemonStop, runDaemonStatus, and
runDaemonAttach currently ignore unexpected extra arguments passed by the user,
which can mask mistakes like typos. Add validation after the help check in each
function to verify that no extra arguments remain; if unexpected arguments are
present, return writeDaemonUsage with the appropriate exit code instead of
continuing execution. This should be applied at: runDaemonStop function (lines
160-163), runDaemonStatus function (lines 181-184), and runDaemonAttach function
(lines 273-282).

In `@internal/daemon/lock.go`:
- Around line 32-45: The `fmt.Fprintf` call in the `acquireLock` function
ignores its error return value, which means if writing the PID fails (e.g., due
to disk full), the lock file will be left incomplete or empty. This causes
`readPidFile` to fail parsing it, incorrectly treating the stale lock as
unclaimed and breaking the single-instance guarantee. Capture the error from
`fmt.Fprintf` instead of discarding it with an underscore, and if an error
occurs, close the file and remove the lock file before returning the error,
similar to how you handle the `f.Close()` error.

---

Nitpick comments:
In `@internal/daemon/session.go`:
- Around line 178-225: The SessionManager accumulates finished sessions
indefinitely in the sessions map because completed sessions are never removed.
In the Start method, after sess.finish(code, err) is called in the background
goroutine, add cleanup logic to remove the session from m.sessions map. Lock
m.mu before removing the session from the map to avoid concurrent access issues.
This ensures memory is properly released when sessions complete, preventing
unbounded growth in long-running daemons.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e00286c7-8bf7-47b1-b535-edf00bc0c69c

📥 Commits

Reviewing files that changed from the base of the PR and between 355b76c and 5234426.

📒 Files selected for processing (25)
  • internal/cli/app.go
  • internal/cli/daemon.go
  • internal/cli/daemon_test.go
  • internal/daemon/backoff.go
  • internal/daemon/backoff_test.go
  • internal/daemon/client.go
  • internal/daemon/launcher.go
  • internal/daemon/launcher_test.go
  • internal/daemon/lock.go
  • internal/daemon/lock_posix.go
  • internal/daemon/lock_test.go
  • internal/daemon/lock_windows.go
  • internal/daemon/paths.go
  • internal/daemon/pool.go
  • internal/daemon/pool_test.go
  • internal/daemon/protocol.go
  • internal/daemon/protocol_test.go
  • internal/daemon/server.go
  • internal/daemon/server_test.go
  • internal/daemon/session.go
  • internal/daemon/session_test.go
  • internal/daemon/socket.go
  • internal/daemon/socket_posix.go
  • internal/daemon/socket_windows.go
  • internal/daemon/status.go

Comment thread internal/cli/app.go
Comment thread internal/cli/daemon.go
Comment thread internal/daemon/lock.go
…e error, bounded sessions

- cli/app.go: list `daemon` in `zero --help` so the command is discoverable.
- cli/daemon: `stop`/`status` reject extra arguments; `attach` rejects more than
  one session id (fail fast on typos instead of silently ignoring tokens).
- daemon/lock: check the PID fmt.Fprintf error — a partial/failed write would
  leave a malformed lock file that another process reads as stale and wrongly
  reclaims, breaking the single-instance guarantee; on write failure remove the
  file and return the error.
- daemon/session: bound the registry with MaxSessions (default 256) — evict the
  oldest FINISHED sessions once past the cap so a long-running daemon doesn't
  accumulate sessions without limit; running/queued sessions are never evicted, so
  attach-after-finish for recent sessions is preserved.

Tests: CLI extra-arg rejection; finished-over-cap eviction + running-never-evicted.
Gates: gofmt, vet, build, test (incl -race), staticcheck (no new), govulncheck (0),
deadcode (no new), GOOS=linux+windows cross-compile.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Also addressed the session-retention nitpick in 5efd12c: SessionManager now bounds the registry with MaxSessions (default 256) and evicts the oldest finished sessions past the cap; running/queued sessions are never evicted, so attach-after-finish for recent sessions still works. Tests: TestSessionManagerEvictsFinishedOverCap + TestSessionManagerKeepsRunningOverCap.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve. I do not see merge blockers.

What I checked:

  • daemon control plane: framed protocol, handshake, max-frame bound, single-instance lock, stale-lock recovery, status, and shutdown path
  • worker/session lifecycle: bounded pool, retries/tempfail/permanent failure handling, attach history, bounded retained sessions, and drain/kill behavior
  • sandbox invariant: worker launcher scrubs sandbox reentrancy markers while preserving policy env, so daemon workers re-enter sandbox instead of silently bypassing it
  • D comparison: D's daemon path is mostly an in-process/IPC adapter. This PR's local socket supervisor is heavier, but appropriate for Go-native Zero. Compared with D, Zero is more protective around explicit locking, framed protocol, owner-local runtime files, and sandbox re-entry.

Validation:

  • git diff --check
  • go test ./internal/daemon -count=1
  • go test ./internal/cli -run TestDaemon -count=1
  • go vet ./internal/daemon ./internal/cli
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke

Non-blocking follow-up:

  • Consider hardening secureSocketParent to chmod or refuse an existing POSIX runtime directory that is not owner-only. MkdirAll(..., 0700) protects newly-created dirs, but does not repair pre-existing permissive dirs. The socket chmod and default per-user locations make this acceptable for this PR, so I would not block merge on it.

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.

2 participants