Skip to content

Commit f5c8bf5

Browse files
css521claude
andcommitted
feat: zero-downtime auto-upgrade for the daemon
The daemon can hot-swap its own binary to a newer release WITHOUT interrupting running agents. It re-execs in place (same PID) via unix.Exec, inheriting the held repo flock and the API listener sockets through CLOEXEC-cleared fds, and re-adopts the still-running agent child processes — so only the control-plane binary changes underneath them, with no lock gap and no dropped connection. New internal/upgrade package: - source: GitHub releases by default, or a mirror via AGENTENV_UPDATE_URL (latest.txt + agentenv_<ver>_<os>_<arch>.tar.gz + checksums.txt) - verify: SHA256 against checksums.txt, then a `<new-binary> version` smoke test — either failing keeps the old daemon serving - schedule: poll every AGENTENV_UPDATE_INTERVAL (default 1h), apply inside AGENTENV_UPDATE_WINDOW (e.g. 02:00-04:00), or on demand - handoff: CLOEXEC clearing, proc manifest, atomic binary rename with rollback, and the execve Triggers: automatic in the maintenance window, or `agentenv upgrade [--version X]` / `agentenv ctl upgrade` on demand (bypasses the window). Dev/commit builds don't auto-upgrade (no comparable version); a manual --version still works. supervise is out of scope (foreground PTY agent). Supporting changes: - repo: AdoptProc (per-PID Wait4 reaper — never a global wait4(-1) that would steal os/exec's cmd.Wait exit status), LockFromFD, Kill/killAll handle adopted procs (unix.Kill by pid) - api: Gate (shared writer lock so a hot upgrade can't fire mid-mutation), ServeListener / ServeHTTPListener (daemon owns the listeners for fd handoff), and the out-of-band "upgrade" op via an UpgradeHandler interface (breaks the api→upgrade import cycle) - cli: cmdDaemon gains a resume path (rebuild lock/listeners from inherited fds, re-adopt procs); Run skips AcquireLock on the re-exec path; new `upgrade` command with transparent ctl routing - protocol: Version/Force request fields, Message response field Verified end-to-end on Linux (scripts/e2e_upgrade.sh): daemon re-execs v0.3.0 -> v0.4.0 keeping the same PID, the socket stays served, the flock is never released, and a spawned agent is re-adopted and keeps running uninterrupted across the swap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5e8520e commit f5c8bf5

24 files changed

Lines changed: 2193 additions & 48 deletions

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,27 @@ All notable changes to this project are documented here. The format is based on
44
[Keep a Changelog](https://keepachangelog.com/), and the project aims to follow
55
[Semantic Versioning](https://semver.org/).
66

7+
## [0.3.1]
8+
9+
### Added
10+
- **Zero-downtime auto-upgrade** (`agentenv daemon`): the daemon can hot-swap
11+
its own binary to a newer release without interrupting running agents. It
12+
re-execs in place (same PID), inheriting the held repo flock and the API
13+
listener sockets via CLOEXEC-cleared fds, and re-adopts the still-running
14+
agent child processes — so the control-plane binary changes underneath them
15+
with no lock gap and no dropped connection.
16+
- New `internal/upgrade` package: release source (GitHub by default, or a
17+
mirror via `AGENTENV_UPDATE_URL` serving `latest.txt` +
18+
`agentenv_<ver>_<os>_<arch>.tar.gz` + `checksums.txt`), SHA256 verification,
19+
`<new-binary> version` smoke test, maintenance-window scheduler, and the
20+
execve handoff.
21+
- Triggers: automatic within `AGENTENV_UPDATE_WINDOW` (e.g. `02:00-04:00`;
22+
polled every `AGENTENV_UPDATE_INTERVAL`, default 1h), or on demand via
23+
`agentenv upgrade [--version X]` / `agentenv ctl upgrade` .
24+
- Safety: checksum + smoke test gate the swap; on any failure the old daemon
25+
keeps serving. Dev/commit builds don't auto-upgrade (manual `--version`
26+
still works). `supervise` is out of scope (foreground PTY agent).
27+
728
## [0.3.0] - 2026-06-17
829

930
### Added

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ agentenv exec -- <cmd...> run one command in the env (scripting/CI)
9090
9191
# out-of-band client (talks to a running daemon/supervise, no repo lock needed)
9292
agentenv ctl <op> [args] log | head | branches | exec | checkout | delete | diff | ...
93+
agentenv upgrade [--version X] zero-downtime hot-upgrade of a running daemon (re-exec in place)
9394
9495
# setup / history / inspection
9596
agentenv init --from <dir|/> | --tarball <path|URL>
@@ -108,6 +109,39 @@ agentenv gc reclaim disk from sparsified snapshots
108109
Env: AGENTENV_ROOT (default /agentfs), AGENTENV_SOCKET, AGENTENV_BACKEND,
109110
AGENTENV_HTTP (=":PORT" / "127.0.0.1:PORT"), AGENTENV_HTTP_TOKEN (required for non-loopback HTTP),
110111
AGENTENV_KEEP_RECENT, AGENTENV_IGNORE.
112+
Auto-upgrade (daemon): AGENTENV_UPDATE_URL (mirror base; default GitHub releases),
113+
AGENTENV_UPDATE_REPO (owner/name for GitHub), AGENTENV_UPDATE_INTERVAL (poll cadence,
114+
default 1h; "off" disables), AGENTENV_UPDATE_WINDOW ("HH:MM-HH:MM" local maintenance window).
115+
```
116+
117+
## Zero-downtime auto-upgrade
118+
119+
`agentenv daemon` can hot-swap its own binary to a newer release **without
120+
interrupting running agents**. It re-execs in place (same PID), so the held repo
121+
lock, the API sockets, and every spawned agent process survive the swap — only
122+
the control-plane binary changes underneath them.
123+
124+
- **Discovery**: the daemon polls a release source every `AGENTENV_UPDATE_INTERVAL`
125+
(GitHub releases by default, or a mirror via `AGENTENV_UPDATE_URL`). A mirror
126+
serves `latest.txt`, `agentenv_<ver>_<os>_<arch>.tar.gz`, and `checksums.txt`.
127+
- **Safety**: a candidate is SHA256-verified against `checksums.txt` and
128+
smoke-tested (`<new-binary> version`) before anything is swapped. If either
129+
fails, the daemon keeps serving the old version.
130+
- **When**: applied automatically inside `AGENTENV_UPDATE_WINDOW` (e.g.
131+
`02:00-04:00`; omit for "any time"), or on demand — bypassing the window —
132+
via `agentenv upgrade [--version vX.Y.Z]`, `agentenv ctl upgrade`, or the
133+
`agentenv__upgrade` MCP tool.
134+
- **Scope**: auto-upgrade is off for dev/commit builds (no comparable version);
135+
a manual `--version` still works. `supervise` is not hot-upgraded (its
136+
interactive PTY agent is foreground); use `daemon` for zero-downtime control.
137+
138+
```bash
139+
# poll GitHub hourly, apply only between 02:00 and 04:00 local
140+
AGENTENV_UPDATE_WINDOW=02:00-04:00 agentenv daemon
141+
142+
# or upgrade a running daemon right now
143+
agentenv upgrade # to the latest release
144+
agentenv upgrade --version v0.4.0
111145
```
112146

113147
## Running your agent transparently (zero changes)

internal/api/http.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,23 @@ const HTTPVersion = "0.3.0"
4242
// `Authorization: Bearer <token>` — non-loopback addresses without a token are
4343
// rejected at startup as a safety guard. Compatible with the socket dispatch:
4444
// both paths call the same r.* and the same repo handles the locking.
45-
func ServeHTTP(ctx context.Context, r *repo.Repo, _ *repo.Capturer, addr, token string) error {
45+
func ServeHTTP(ctx context.Context, r *repo.Repo, c *repo.Capturer, addr, token string) error {
4646
if token == "" && !isLoopback(addr) {
4747
return fmt.Errorf("refusing to bind HTTP on non-loopback %s without AGENTENV_HTTP_TOKEN — set a token or bind 127.0.0.1", addr)
4848
}
49+
ln, err := net.Listen("tcp", addr)
50+
if err != nil {
51+
return err
52+
}
53+
return ServeHTTPListener(ctx, r, c, ln, token)
54+
}
4955

56+
// ServeHTTPListener runs the HTTP API on an already-created listener. The
57+
// hot-upgrade path needs this seam: the daemon owns the tcp listener so it can
58+
// clear FD_CLOEXEC on it and hand the fd to the new image across execve, which
59+
// rebuilds the listener with net.FileListener and calls this again — so an
60+
// in-flight bind is never re-attempted and the port never flaps.
61+
func ServeHTTPListener(ctx context.Context, r *repo.Repo, _ *repo.Capturer, ln net.Listener, token string) error {
5062
mux := http.NewServeMux()
5163
cfg := huma.DefaultConfig("agentenv", HTTPVersion)
5264
cfg.Info.Description = "REST surface for the agentenv daemon. " +
@@ -68,11 +80,7 @@ func ServeHTTP(ctx context.Context, r *repo.Repo, _ *repo.Capturer, addr, token
6880
handler = bearerAuth(mux, token)
6981
}
7082

71-
srv := &http.Server{Addr: addr, Handler: handler}
72-
ln, err := net.Listen("tcp", addr)
73-
if err != nil {
74-
return err
75-
}
83+
srv := &http.Server{Handler: handler}
7684
fmt.Fprintf(os.Stderr, "agentenv HTTP listening on http://%s (docs http://%s/docs)\n", ln.Addr(), ln.Addr())
7785
go func() {
7886
<-ctx.Done()
@@ -84,6 +92,17 @@ func ServeHTTP(ctx context.Context, r *repo.Repo, _ *repo.Capturer, addr, token
8492
return nil
8593
}
8694

95+
// GuardHTTP returns an error if binding addr without a token would expose the
96+
// API beyond loopback. Exported so the daemon, which now owns the TCP listener
97+
// itself (for fd handoff across a hot upgrade), can apply the same guard
98+
// ServeHTTP applies before it binds.
99+
func GuardHTTP(addr, token string) error {
100+
if token == "" && !isLoopback(addr) {
101+
return fmt.Errorf("refusing to bind HTTP on non-loopback %s without AGENTENV_HTTP_TOKEN — set a token or bind 127.0.0.1", addr)
102+
}
103+
return nil
104+
}
105+
87106
// isLoopback reports whether addr binds only to a loopback interface. A bare
88107
// ":PORT" (host empty) in Go's net.Listen binds ALL interfaces, so we treat
89108
// that as non-loopback — the safer default for the no-token guard.

internal/api/server.go

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,24 @@ type (
2929
response = protocol.Response
3030
)
3131

32+
// Gate serializes mutating operations. The daemon shares ONE Gate between the
33+
// socket accept loop (writer lock per mutating op) and the upgrade coordinator
34+
// (which takes the writer lock right before it re-execs), so a hot upgrade can
35+
// never fire mid-checkout. It embeds sync.RWMutex so read-only ops still run
36+
// concurrently under RLock.
37+
type Gate struct{ sync.RWMutex }
38+
39+
// UpgradeHandler processes the out-of-band "upgrade" op. It's an interface (not
40+
// a concrete *upgrade.Coordinator) purely to break the api→upgrade→api import
41+
// cycle: internal/upgrade imports this package for Gate, so api must not import
42+
// it back.
43+
type UpgradeHandler interface {
44+
// HandleUpgrade handles one "upgrade" request. It writes any result
45+
// frame(s) via send. On a successful hot-upgrade it re-execs and NEVER
46+
// returns; otherwise it returns after sending exactly one result frame.
47+
HandleUpgrade(req protocol.Request, send func(protocol.Response) error)
48+
}
49+
3250
// Serve listens on sockPath and serves requests until ctx is cancelled.
3351
//
3452
// Security: the socket is created with default fs perms (~0755 after umask),
@@ -47,6 +65,23 @@ func Serve(ctx context.Context, r *repo.Repo, c *repo.Capturer, sockPath string)
4765
ln.Close()
4866
return err
4967
}
68+
// supervise and plain daemon-without-upgrade get a fresh Gate and no
69+
// upgrade handler. The daemon that wants hot-upgrade builds its own
70+
// listener (so it can hand off the fd across execve) and calls
71+
// ServeListener directly with a shared Gate + coordinator.
72+
return ServeListener(ctx, r, c, ln, &Gate{}, nil)
73+
}
74+
75+
// ServeListener serves the JSON protocol on an already-created listener. It
76+
// does NOT remove/chmod/bind — the caller owns the listener's lifecycle. This
77+
// is the seam the hot-upgrade path needs: the daemon builds the unix listener
78+
// itself, hands ServeListener a shared Gate (so the upgrade coordinator can
79+
// serialize against mutating ops) and an UpgradeHandler, and on re-exec the new
80+
// image rebuilds the listener from the inherited fd and calls this again.
81+
func ServeListener(ctx context.Context, r *repo.Repo, c *repo.Capturer, ln net.Listener, gate *Gate, up UpgradeHandler) error {
82+
if gate == nil {
83+
gate = &Gate{}
84+
}
5085
// The listening/uid banner is left to the caller: `daemon` and headless
5186
// `supervise` print it (useful operational logging + the cross-uid trap
5287
// hint), while interactive `supervise` stays silent so it doesn't pollute
@@ -55,17 +90,16 @@ func Serve(ctx context.Context, r *repo.Repo, c *repo.Capturer, sockPath string)
5590
<-ctx.Done()
5691
ln.Close()
5792
}()
58-
// RWMutex (was a plain Mutex) so read-only ops (log, head, branches, show,
93+
// The Gate is an RWMutex so read-only ops (log, head, branches, show,
5994
// diff, ps, status…) can run concurrently with each other. Without this, a
6095
// long-running exec held the global lock and starved every other client —
6196
// e.g. an agent's `make` blocked an operator's `agentenv ctl log`.
62-
var mu sync.RWMutex
6397
for {
6498
conn, err := ln.Accept()
6599
if err != nil {
66100
return nil // listener closed via stop
67101
}
68-
go handle(r, c, conn, &mu)
102+
go handle(r, c, conn, gate, up)
69103
}
70104
}
71105

@@ -76,7 +110,7 @@ var readOnlyOps = map[string]bool{
76110
"show": true, "diff": true, "ps": true,
77111
}
78112

79-
func handle(r *repo.Repo, c *repo.Capturer, conn net.Conn, mu *sync.RWMutex) {
113+
func handle(r *repo.Repo, c *repo.Capturer, conn net.Conn, gate *Gate, up UpgradeHandler) {
80114
defer conn.Close()
81115
// json.Decoder streams over the connection: no max-line ceiling (bufio.Scanner
82116
// had a 16MB cap, after which it silently failed), and EOF / parse errors
@@ -89,26 +123,40 @@ func handle(r *repo.Repo, c *repo.Capturer, conn net.Conn, mu *sync.RWMutex) {
89123
if err := dec.Decode(&req); err != nil {
90124
return // EOF or malformed: drop the connection
91125
}
126+
// upgrade is handled entirely by the coordinator: it takes the Gate's
127+
// writer lock ITSELF (so we must NOT hold it here — that would
128+
// deadlock) and, on success, re-execs and never returns. On the happy
129+
// path it writes an ack frame just before exec, so the client that
130+
// triggered the upgrade reads that frame and then sees EOF as the
131+
// connection dies with the old image.
132+
if req.Op == "upgrade" {
133+
if up == nil {
134+
_ = enc.Encode(response{Error: "upgrade not supported in this mode (only `agentenv daemon` hot-upgrades)"})
135+
continue
136+
}
137+
up.HandleUpgrade(req, func(fr response) error { return enc.Encode(fr) })
138+
continue
139+
}
92140
// Read-only ops use the read lock so concurrent log/diff/show calls
93141
// don't serialize behind a long-running exec. Mutating ops (and exec,
94142
// which mutates via auto-snapshot) take the writer lock.
95143
if readOnlyOps[req.Op] {
96-
mu.RLock()
144+
gate.RLock()
97145
err := enc.Encode(dispatch(r, c, req))
98-
mu.RUnlock()
146+
gate.RUnlock()
99147
if err != nil {
100148
return
101149
}
102150
continue
103151
}
104-
mu.Lock()
152+
gate.Lock()
105153
if req.Op == "exec" {
106154
dispatchExecStream(r, c, enc, req)
107155
} else if err := enc.Encode(dispatch(r, c, req)); err != nil {
108-
mu.Unlock()
156+
gate.Unlock()
109157
return
110158
}
111-
mu.Unlock()
159+
gate.Unlock()
112160
}
113161
}
114162

internal/cli/cli.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ func usage() {
6969
fmt.Fprintf(os.Stderr, " %-26s %s\n", c.name+" "+c.args, c.summary)
7070
}
7171
fmt.Fprintln(os.Stderr, " ctl <op> [--socket p] drive a running daemon/supervise out-of-band (e.g. ctl checkout <id>)")
72+
fmt.Fprintln(os.Stderr, " upgrade [--version X] hot-upgrade a running daemon to a new release (zero-downtime re-exec)")
7273
fmt.Fprintln(os.Stderr, " mcp [--socket p] MCP server over stdio (for Claude Code / other MCP hosts)")
7374
fmt.Fprintln(os.Stderr, " version print version")
7475
fmt.Fprintln(os.Stderr, `

internal/cli/ctl.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ func cmdCtl(args []string) error {
4444
req["cmd"] = strings.Join(a, " ")
4545
case "commit":
4646
req["message"] = strings.Join(a, " ")
47+
case "upgrade":
48+
// Manual upgrade always bypasses the maintenance window (force=true).
49+
// --version pins an explicit target; omit for "latest".
50+
if v := flagValue(a, "--version"); v != "" {
51+
req["version"] = v
52+
}
53+
req["force"] = true
4754
case "kill":
4855
if len(a) < 1 {
4956
return fmt.Errorf("usage: agentenv ctl kill <pid>")
@@ -161,6 +168,12 @@ func ctlPrint(op string, r *protocol.Response) error {
161168
for _, p := range r.Procs {
162169
fmt.Printf("%d\t%s\n", p.PID, p.Args)
163170
}
171+
case "upgrade":
172+
if r.Message != "" {
173+
fmt.Println(r.Message)
174+
} else {
175+
fmt.Println("ok")
176+
}
164177
case "tag":
165178
switch {
166179
case len(r.Tags) > 0:

internal/cli/run.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,21 @@ import (
1313
"path/filepath"
1414

1515
"github.com/css521/agentenv/internal/repo"
16+
"github.com/css521/agentenv/internal/upgrade"
1617
)
1718

19+
// binVersion is the running binary's version string (from main.resolveVersion),
20+
// stashed by Run so command handlers that need it — cmdDaemon, for the upgrade
21+
// coordinator — can read it without threading it through every handler.
22+
var binVersion string
23+
1824
// ctlRoutable reports whether a mutating command has an out-of-band (ctl)
1925
// equivalent it can be transparently routed to when a daemon/supervise holds
2026
// the repo lock. Session-establishing commands (init/supervise/daemon/shell)
2127
// are excluded — they must own the lock themselves.
2228
func ctlRoutable(name string) bool {
2329
switch name {
24-
case "checkout", "commit", "exec", "tag", "gc", "tournament", "delete":
30+
case "checkout", "commit", "exec", "tag", "gc", "tournament", "delete", "upgrade":
2531
return true
2632
}
2733
return false
@@ -53,6 +59,7 @@ func liveControlSocket(root string) string {
5359
// of the repo lock (and any other defers a handler set up). main() is the only
5460
// place allowed to terminate the process.
5561
func Run(version string) int {
62+
binVersion = version
5663
args := os.Args[1:]
5764
if len(args) == 0 {
5865
usage()
@@ -86,6 +93,15 @@ func Run(version string) int {
8693
return 1
8794
}
8895
return 0
96+
case "upgrade":
97+
// Out-of-band: ask a running daemon to hot-upgrade its own binary
98+
// (zero-downtime re-exec). Pure socket client — no repo/lock — so it
99+
// works while the daemon holds the lock. `agentenv upgrade [--version X]`.
100+
if err := cmdCtl(append([]string{"upgrade"}, rest...)); err != nil {
101+
fmt.Fprintln(os.Stderr, "error:", err)
102+
return 1
103+
}
104+
return 0
89105
}
90106

91107
cmd := commands[name]
@@ -109,8 +125,14 @@ func Run(version string) int {
109125
// session can be writing it, producing a torn read or an inconsistent
110126
// in-memory DAG. Read-only commands skip the lock (they only read, they
111127
// don't write back, so a slightly stale view is acceptable).
128+
// Hot-upgrade re-exec: the daemon inherits the flock via a CLOEXEC-cleared
129+
// fd, so it must NOT AcquireLock (a second open file description would
130+
// EWOULDBLOCK against the one still held). cmdDaemon rebuilds the *Lock from
131+
// the inherited fd. All other commands take the lock normally.
132+
reexecDaemon := name == "daemon" && upgrade.IsReexec()
133+
112134
var lock *repo.Lock
113-
if cmd.mutates {
135+
if cmd.mutates && !reexecDaemon {
114136
var err error
115137
lock, err = repo.AcquireLock(root)
116138
if err != nil {
@@ -146,6 +168,9 @@ func Run(version string) int {
146168
fmt.Fprintln(os.Stderr, "error:", err)
147169
return 1
148170
}
171+
if lock != nil {
172+
r.SetLock(lock) // let long-running modes reach the flock fd (hot upgrade)
173+
}
149174

150175
if err := cmd.run(r, rest); err != nil {
151176
// ExitError = "the inner command produced a non-zero exit; propagate it

0 commit comments

Comments
 (0)