mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 13:11:30 -10:00
* feat(daemon): add `zero daemon` — worker pool + session supervisor over 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. * daemon: address review — help listing, extra-arg rejection, lock write 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.
124 lines
3.4 KiB
Go
124 lines
3.4 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// isolateDaemonPaths points DefaultPaths at a temp dir so the test never touches
|
|
// a real daemon on the dev machine.
|
|
func isolateDaemonPaths(t *testing.T) {
|
|
t.Helper()
|
|
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
|
|
}
|
|
|
|
func runDaemonCLI(t *testing.T, args ...string) (int, string, string) {
|
|
t.Helper()
|
|
var out, errb bytes.Buffer
|
|
code := runDaemon(args, &out, &errb, appDeps{})
|
|
return code, out.String(), errb.String()
|
|
}
|
|
|
|
func TestDaemonUsage(t *testing.T) {
|
|
code, _, _ := runDaemonCLI(t)
|
|
if code != exitUsage {
|
|
t.Fatalf("no-args exit = %d, want exitUsage", code)
|
|
}
|
|
code, out, _ := runDaemonCLI(t, "--help")
|
|
if code != exitSuccess || !strings.Contains(out, "Usage: zero daemon") {
|
|
t.Fatalf("--help exit=%d out=%q", code, out)
|
|
}
|
|
}
|
|
|
|
func TestDaemonUnknownSubcommand(t *testing.T) {
|
|
code, _, errb := runDaemonCLI(t, "frobnicate")
|
|
if code != exitUsage {
|
|
t.Fatalf("unknown subcommand exit = %d, want exitUsage", code)
|
|
}
|
|
if !strings.Contains(errb, "unknown daemon subcommand") {
|
|
t.Fatalf("stderr = %q, want unknown-subcommand message", errb)
|
|
}
|
|
}
|
|
|
|
func TestDaemonRunRequiresSession(t *testing.T) {
|
|
isolateDaemonPaths(t)
|
|
code, _, errb := runDaemonCLI(t, "run", "--prompt", "hi")
|
|
if code != exitUsage {
|
|
t.Fatalf("run without --session exit = %d, want exitUsage", code)
|
|
}
|
|
if !strings.Contains(errb, "--session") {
|
|
t.Fatalf("stderr = %q, want a --session hint", errb)
|
|
}
|
|
}
|
|
|
|
func TestDaemonRunRequiresPromptOrArgs(t *testing.T) {
|
|
isolateDaemonPaths(t)
|
|
code, _, errb := runDaemonCLI(t, "run", "--session", "s1")
|
|
if code != exitUsage {
|
|
t.Fatalf("run without prompt/args exit = %d, want exitUsage", code)
|
|
}
|
|
if !strings.Contains(errb, "--prompt") {
|
|
t.Fatalf("stderr = %q, want a --prompt hint", errb)
|
|
}
|
|
}
|
|
|
|
func TestDaemonStopWhenNotRunning(t *testing.T) {
|
|
isolateDaemonPaths(t)
|
|
code, out, _ := runDaemonCLI(t, "stop")
|
|
if code != exitSuccess {
|
|
t.Fatalf("stop (not running) exit = %d, want exitSuccess", code)
|
|
}
|
|
if !strings.Contains(out, "not running") {
|
|
t.Fatalf("stop output = %q, want 'not running'", out)
|
|
}
|
|
}
|
|
|
|
func TestDaemonStatusWhenNotRunning(t *testing.T) {
|
|
isolateDaemonPaths(t)
|
|
code, out, _ := runDaemonCLI(t, "status")
|
|
if code != exitSuccess {
|
|
t.Fatalf("status (not running) exit = %d, want exitSuccess", code)
|
|
}
|
|
if !strings.Contains(out, "not running") {
|
|
t.Fatalf("status output = %q, want 'not running'", out)
|
|
}
|
|
}
|
|
|
|
func TestDaemonAttachRequiresSession(t *testing.T) {
|
|
isolateDaemonPaths(t)
|
|
code, _, errb := runDaemonCLI(t, "attach")
|
|
if code != exitUsage {
|
|
t.Fatalf("attach without session exit = %d, want exitUsage", code)
|
|
}
|
|
if !strings.Contains(errb, "session") {
|
|
t.Fatalf("stderr = %q, want a session hint", errb)
|
|
}
|
|
}
|
|
|
|
func TestDaemonRunWhenNotRunning(t *testing.T) {
|
|
isolateDaemonPaths(t)
|
|
code, _, errb := runDaemonCLI(t, "run", "--session", "s1", "--prompt", "hello")
|
|
if code != exitCrash {
|
|
t.Fatalf("run (no daemon) exit = %d, want exitCrash", code)
|
|
}
|
|
if !strings.Contains(errb, "not running") {
|
|
t.Fatalf("stderr = %q, want 'not running'", errb)
|
|
}
|
|
}
|
|
|
|
func TestDaemonSubcommandsRejectExtraArgs(t *testing.T) {
|
|
isolateDaemonPaths(t)
|
|
cases := [][]string{
|
|
{"stop", "oops"},
|
|
{"status", "oops"},
|
|
{"attach", "s1", "extra"},
|
|
}
|
|
for _, args := range cases {
|
|
code, _, errb := runDaemonCLI(t, args...)
|
|
if code != exitUsage {
|
|
t.Fatalf("%v exit = %d, want exitUsage (reject extra args); stderr=%q", args, code, errb)
|
|
}
|
|
}
|
|
}
|