eleven / messenger Public

server: converge awake hibernating instances onto shipped builds (+ update status --probe) (#834)

* server: update status --probe + awake-convergence design doc

The convergence issue (awake hibernate-managed instances keep serving the
old binary after a ship — the box repoints the symlink but a running
process doesn't follow it) split into a safe now-piece + a design proposal.

Now (safe, read-only, shipped here):
- `lchat update status --probe` reads each instance's socket /api/version —
  the build it's ACTUALLY serving — alongside the symlink target, flags awake-
  but-stale instances (⚠), and prints how to converge them. Stops the false
  'current' reading that made a shipped release look fully rolled out when the
  live processes were still on older builds. probeVersion helper + test.

Proposal (docs/awake-instance-convergence.md, NOT implemented):
- The real fix — cycle an awake child onto the new symlink via the existing
  graceful stop, gated on inflight==0 so no live WebSocket is dropped — needs
  a new per-host router control channel (router.go flags it as a 'later step')
  and changes a deliberate policy. It touches the live-serving path that fronts
  every member space, so it's written up for human review before any code lands,
  with the open decisions (gate policy, control-channel surface, urgency) called
  out. No urgency: the 72h bake window already spaces releases, and the lazy
  next-wake path eventually converges most spaces.

go test green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server: converge awake hibernating instances onto shipped builds

A running activator child never re-reads its live/<host> symlink, so an
awake hibernate-managed instance kept serving its old binary after a ship
repointed the symlink. Idle-hibernate converges sleeping spaces on their next
wake, but an always-awake space (every bot-sidecar host) never idles, so it
served the stale build indefinitely — and every status surface reads the
symlink, so the fleet looked converged while it wasn't.

Close the gap in the sweep the activator already runs: the backend's start()
reads liveVersion(host) immediately before cmd.Start() and returns it, so the
entry records the build the kernel actually execed (reading it back in wake()
after the up-to-30s health wait would let a concurrent ship record the new
target while the child runs the old binary — the sweep would then never
converge it). A fourth staleness case, alongside lost-lease / dead-child /
idle: when the live symlink has diverged from spawnVersion for longer than a
grace window (90s), gracefully cycle the child via the same beginStopLocked
path — SIGTERM drain, final S3 checkpoint, lease release — leaving it asleep so
a member request (or the due-time index for a scheduled charter) wakes it onto
the new binary.

The grace/reset logic is a pure convergeDecision helper (unit-tested directly):
first drift starts the clock, a rollback to spawnVersion clears it, a transient
read error is 'unknown' and neither cycles nor resets. Rate-limited to one cycle
per sweep tick so a fleet-wide ship staggers instead of blipping every space at
once. Unlike idle-hibernate this can fire with a request in flight (the whole
point — the target spaces never reach inflight==0); the stop drains HTTP just as
systemctl restart does.

No new router control channel and no repoint-site call: the updater/deploy still
just pointLive, and the sweep picks up the drift within one tick (<=30s). Pinned
canaries fall out automatically (their symlink is never repointed). liveVer added
as a sweep-read seam and execVerFn on the fake backend models the exec-time read;
tests cover the cycle, the grace, no-thrash, the per-tick rate limit, the
in-flight-request path, and the full convergeDecision state machine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e24e784d511be1008eead812bee9a1f8e4d3369e parent 4947ae0
6 files changed, +550 −16
  • activator.go +113 −12
  • activator_test.go +181 −2
  • docs/awake-instance-convergence.md +177 −0
  • updater.go +25 −2
  • versions.go +20 −0
  • versions_test.go +34 −0
diff --git a/activator.go b/activator.go
index 13f4411..4552ede 100644
--- a/activator.go
+++ b/activator.go
@@ -133,8 +133,12 @@ type actBackend interface {
// restore materializes the local DB from S3 when it's absent (a no-op when
// the warm-cache copy is still on disk).
restore(ctx context.Context, host, dbPath string) error
- // start spawns the instance process and waits until its socket accepts.
- start(ctx context.Context, rt Route) (actProc, error)
+ // start spawns the instance process and waits until its socket accepts. It
+ // also returns the build it execed (the live/<host> symlink target read at
+ // spawn), so the sweep can later spot a repoint under a still-running child
+ // and converge it. Empty when unknown — the sweep treats that as "don't
+ // cycle" (fail-safe).
+ start(ctx context.Context, rt Route) (actProc, string, error)
// nextDue asks a running instance for its earliest pending scheduled
// delivery (unix seconds; 0 = nothing scheduled). Queried at sleep time,
// while the socket still answers, to feed the due-time index.
@@ -221,6 +225,16 @@ type actEntry struct {
rep actRep
proc actProc
+ // Convergence: the `live/<host>` symlink target this child was spawned
+ // against. A running process never re-reads the symlink, so once a ship
+ // (or `deploy --version`) repoints it, an awake child keeps serving the old
+ // build until it's cycled. spawnVersion lets the sweep notice the drift;
+ // staleSince is when it first did, so a brief transient (a symlink moved
+ // then rolled straight back) doesn't trigger a needless cycle. Both empty/
+ // zero until the wake resolves.
+ spawnVersion string
+ staleSince time.Time
+
// Bot sidecar co-spawn: the sidecar exists exactly while its instance is
// awake. All sidecar fields are written under a.mu in the awake state only
// (wake's attach, the sweeper's restart); a stop flips the state under the
@@ -247,6 +261,47 @@ const (
// behind this timeout is insurance.
const sidecarStopTimeout = 5 * time.Second
+// Build convergence: how long an awake child may serve a symlink-stale binary
+// before the sweep gracefully cycles it, and how many to cycle per tick.
+// - convergeGrace comfortably exceeds one sweep interval (≤30s) and the
+// sidecar's ~32s reconnect, so a genuinely-persistent drift is required —
+// a symlink that moved then rolled straight back never trips it, and a
+// briefly-busy space gets a chance to idle-hibernate (which converges for
+// free) first.
+// - one per tick staggers a fleet-wide ship across sweeps instead of blipping
+// every space's connections at once.
+//
+// Convergence is a latency/observability fix, not urgent (the supply-chain bake
+// window already holds releases 72h), so a leisurely, gentle cadence is right.
+const (
+ convergeGrace = 90 * time.Second
+ maxConvergeCyclesPerSweep = 1
+)
+
+// convergeDecision is the pure state machine behind build convergence: given the
+// symlink target read this tick (cur/readErr), the build the child execed
+// (spawnVer), and when the drift was first seen (staleSince), it says whether to
+// cycle now and returns the staleSince to carry forward. Split out so the grace/
+// reset logic is unit-testable without a live activator. Three cases:
+// - read error: unknown — neither cycle nor clear the grace; retry next tick.
+// - converged / rolled-back / nothing to compare (cur=="" or spawnVer=="" or
+// cur==spawnVer): clear the grace clock, don't cycle.
+// - stale (symlink moved since spawn): start the grace clock on first sight,
+// cycle once the drift has persisted past grace.
+func convergeDecision(cur string, readErr error, spawnVer string, staleSince, now time.Time, grace time.Duration) (cycle bool, nextStale time.Time) {
+ switch {
+ case readErr != nil:
+ return false, staleSince
+ case cur == "" || spawnVer == "" || cur == spawnVer:
+ return false, time.Time{}
+ default:
+ if staleSince.IsZero() {
+ staleSince = now
+ }
+ return now.Sub(staleSince) >= grace, staleSince
+ }
+}
+
// wakeWaitBound caps how long a request queues on someone else's wake or stop
// before giving up with 503 — a wedged wake must not pile up connections
// forever. A var so tests can shrink it.
@@ -270,7 +325,8 @@ type activatorSvc struct {
cfg hibernateConfig
backend actBackend
due actDueStore
- now func() time.Time // a seam for the unit tests' clock
+ now func() time.Time // a seam for the unit tests' clock
+ liveVer func(string) (string, error) // live/<host> symlink target; a seam for tests
// mesh, when non-nil, is the multi-box placement layer (mesh.go): requests
// for hosts whose HRW owner is another live box are proxied there, and the
@@ -292,6 +348,7 @@ func newActivator(cfg hibernateConfig, backend actBackend, due actDueStore) *act
backend: backend,
due: due,
now: time.Now,
+ liveVer: liveVersion,
entries: make(map[string]*actEntry),
}
}
@@ -502,6 +559,13 @@ func (a *activatorSvc) wake(e *actEntry) {
e.sidecarBackoff = 0
e.sidecarRetryAt = time.Time{} // a wake-time spawn failure retries on the next sweep
e.lastActive = a.now()
+ // The build this child actually execed, captured at Start() inside the
+ // backend (not re-read here — a ship during the wake window would make a
+ // re-read name the new target while the process runs the old binary, and
+ // the sweep would never converge it). Empty = unknown → the sweep won't
+ // cycle. Fresh wake, so it can't be stale yet.
+ e.spawnVersion = w.spawnVersion
+ e.staleSince = time.Time{}
}
a.mu.Unlock()
close(e.ready)
@@ -516,6 +580,7 @@ type wokenInstance struct {
proc actProc
sidecar actProc
sidecarWanted bool
+ spawnVersion string // live/<host> target read at exec — for build convergence
}
func (a *activatorSvc) doWake(ctx context.Context, rt Route) (*wokenInstance, error) {
@@ -551,7 +616,7 @@ func (a *activatorSvc) doWake(ctx context.Context, rt Route) (*wokenInstance, er
// on boot, so waiting for the socket guarantees the file exists for
// litestream to open. Writes that land in the gap are picked up by the
// replica's first sync.
- proc, err := a.backend.start(ctx, rt)
+ proc, spawnVer, err := a.backend.start(ctx, rt)
if err != nil {
_ = lease.release(ctx)
return nil, fmt.Errorf("start %s: %w", host, err)
@@ -572,7 +637,7 @@ func (a *activatorSvc) doWake(ctx context.Context, rt Route) (*wokenInstance, er
// (its marked requests pass through the router while we're awake). A
// spawn failure is loud but never fails the wake: members come before
// bots, and the sweeper retries with backoff.
- w := &wokenInstance{rep: rep, proc: proc}
+ w := &wokenInstance{rep: rep, proc: proc, spawnVersion: spawnVer}
if a.backend.hasSidecar(host) {
w.sidecarWanted = true
if sc, err := a.backend.startSidecar(ctx, rt); err != nil {
@@ -705,6 +770,7 @@ func (a *activatorSvc) sweepOnce() {
log.Printf("activator: due-time sweep: %v", err)
}
a.mu.Lock()
+ cycled := 0 // build-convergence cycles started this tick (rate-limited)
for _, e := range a.entries {
if e.state != actAwake {
continue
@@ -724,6 +790,31 @@ func (a *activatorSvc) sweepOnce() {
a.beginStopLocked(e)
continue
}
+ // Converge onto a shipped build. A running child never re-reads
+ // live/<host>, so once a ship or `deploy --version` repoints the symlink
+ // this awake child is serving a stale binary. Idle spaces converge for
+ // free on their next wake; this cycle is for the always-awake ones —
+ // notably every bot-sidecar host, which never sleeps — that otherwise
+ // serve the old build indefinitely. Gracefully cycle: beginStopLocked
+ // drains via SIGTERM, takes the final checkpoint, releases the lease, and
+ // leaves the host asleep; a member request (or the due-time index for
+ // scheduled charters) then wakes it onto the new binary — the same clean
+ // cycle `systemctl restart` gives a unit-managed host. Unlike idle
+ // hibernation this can fire with a request in flight; the stop still
+ // drains HTTP gracefully and reconnecting clients coalesce onto the fresh
+ // wake, so a long upload past the ~10s drain is cut exactly as a restart
+ // would cut it. A pinned canary never trips this: the updater leaves its
+ // symlink alone, so spawnVersion still matches. See
+ // docs/awake-instance-convergence.md.
+ cur, readErr := a.liveVer(e.host)
+ cycle, next := convergeDecision(cur, readErr, e.spawnVersion, e.staleSince, now, convergeGrace)
+ e.staleSince = next
+ if cycle && cycled < maxConvergeCyclesPerSweep {
+ log.Printf("activator: %s serving %s but live symlink is now %s — cycling onto the new build", e.host, e.spawnVersion, cur)
+ a.beginStopLocked(e)
+ cycled++
+ continue
+ }
// Sidecar supervision — Restart=always, sweep-driven, no supervisor
// goroutine: a sidecar that died (or failed to spawn at wake) while
// its instance stays awake is respawned with doubling, capped backoff;
@@ -1051,7 +1142,7 @@ func (b *s3Backend) nextDue(ctx context.Context, rt Route) (int64, error) {
// socket and answer. A var so tests can shrink the failure path.
var spawnHealthTimeout = 30 * time.Second
-func (b *s3Backend) start(ctx context.Context, rt Route) (actProc, error) {
+func (b *s3Backend) start(ctx context.Context, rt Route) (actProc, string, error) {
host := rt.Host
dbPath := rt.DBPath
if dbPath == "" {
@@ -1067,17 +1158,17 @@ func (b *s3Backend) start(ctx context.Context, rt Route) (actProc, error) {
if _, err := os.Stat(bin); err != nil {
ver, err := ensureSelfStaged()
if err != nil {
- return nil, fmt.Errorf("stage binary: %w", err)
+ return nil, "", fmt.Errorf("stage binary: %w", err)
}
if err := pointLive(host, ver); err != nil {
- return nil, fmt.Errorf("point live symlink: %w", err)
+ return nil, "", fmt.Errorf("point live symlink: %w", err)
}
}
// The env file is optional, like the unit's "-" prefix.
fileEnv, err := readEnvFile(filepath.Join(instanceDataDir, host+".env"))
if err != nil && !os.IsNotExist(err) {
- return nil, fmt.Errorf("read env file: %w", err)
+ return nil, "", fmt.Errorf("read env file: %w", err)
}
cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath)
@@ -1086,8 +1177,18 @@ func (b *s3Backend) start(ctx context.Context, rt Route) (actProc, error) {
// systemd); there's no per-instance unit to own a log stream anymore.
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
+ // Record the build we're about to exec — read the symlink immediately before
+ // Start() so it names the version the kernel will resolve. Reading it here,
+ // rather than back in wake() after the health wait, closes a real window: a
+ // concurrent ship's pointLive during the up-to-30s wait would otherwise make
+ // us record the NEW target while this child runs the OLD binary, and the
+ // sweep would then think it's already converged and never cycle it. The
+ // residual sub-exec race (a repoint between this read and Start) is in the
+ // safe direction — we'd record the older version, so the sweep cycles once
+ // too often rather than never. Empty on a read error: the sweep won't cycle.
+ spawnVer, _ := liveVersion(host)
if err := cmd.Start(); err != nil {
- return nil, err
+ return nil, "", err
}
p := &childProc{cmd: cmd, done: make(chan struct{})}
@@ -1100,9 +1201,9 @@ func (b *s3Backend) start(ctx context.Context, rt Route) (actProc, error) {
stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout)
defer cancel()
_ = p.stop(stopCtx)
- return nil, err
+ return nil, "", err
}
- return p, nil
+ return p, spawnVer, nil
}
// childEnv approximates a systemd unit's environment from inside the router:
diff --git a/activator_test.go b/activator_test.go
index a24796b..fd70fb5 100644
--- a/activator_test.go
+++ b/activator_test.go
@@ -108,6 +108,13 @@ type fakeBackend struct {
dueVal int64 // what the instance reports at the sleep-time next-due probe
dueErr error // when non-nil, the probe fails (dead socket, missing token, …)
+ // execVerFn models the live/<host> target the child execs at spawn — start()
+ // returns it as the spawnVersion, exactly as s3Backend reads the symlink just
+ // before Start(). Nil → "" (unknown), which disables convergence, so tests
+ // that don't care are unaffected. Point it at the same source as the sweep's
+ // liveVer seam to model a symlink faithfully (both read the current target).
+ execVerFn func() string
+
// Bot sidecar co-spawn. sidecarStaged is hasSidecar's answer (false by
// default, so every pre-co-spawn test doubles as the "host without a
// sidecar spawns none" case); sidecarErr injects spawn failures.
@@ -153,18 +160,23 @@ func (b *fakeBackend) restore(context.Context, string, string) error {
return nil
}
-func (b *fakeBackend) start(context.Context, Route) (actProc, error) {
+func (b *fakeBackend) start(context.Context, Route) (actProc, string, error) {
b.mu.Lock()
b.starts++
b.order = append(b.order, "start:instance")
gate := b.startGate
+ fn := b.execVerFn
proc := &fakeProc{label: "instance", rec: b}
b.procs = append(b.procs, proc)
b.mu.Unlock()
if gate != nil {
<-gate
}
- return proc, nil
+ ver := ""
+ if fn != nil {
+ ver = fn()
+ }
+ return proc, ver, nil
}
func (b *fakeBackend) nextDue(context.Context, Route) (int64, error) {
@@ -393,6 +405,173 @@ func TestActivatorInflightKeepsAwake(t *testing.T) {
waitFor(t, "hibernation", func() bool { return a.entryCount() == 0 })
}
+// A ship repoints live/<host> under an awake child. A running process never
+// re-reads its symlink, so idle-hibernate is the only thing that converges an
+// instance — and an always-awake space (every bot-sidecar host) never idles.
+// The sweep closes that gap: it notices the drift and, after a grace window and
+// rate-limited to one per tick, gracefully cycles the child so its next wake
+// execs the new binary. See docs/awake-instance-convergence.md.
+func TestActivatorConvergesOnSymlinkRepoint(t *testing.T) {
+ a, b, clk, _ := testActivator(t, time.Hour) // idle far away — isolate the convergence path
+ var ver atomic.Value // the live/<host> symlink target; swapped to model a ship
+ ver.Store("v1")
+ // Model one symlink: the child execs whatever it points at now (execVerFn,
+ // frozen into spawnVersion at wake), and the sweep reads the current target
+ // (liveVer). A "ship" swaps ver; the running child keeps its frozen version.
+ a.liveVer = func(string) (string, error) { return ver.Load().(string), nil }
+ b.execVerFn = func() string { return ver.Load().(string) }
+
+ release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if err != nil {
+ t.Fatalf("beginRequest: %v", err)
+ }
+ release()
+
+ // Symlink unchanged: never cycles, however long it stays awake.
+ clk.advance(3 * convergeGrace)
+ a.sweepOnce()
+ if a.entryCount() != 1 {
+ t.Fatal("cycled an instance whose symlink never moved")
+ }
+
+ // Ship: repoint the symlink. The first sweep after only starts the grace
+ // clock — it must not cycle yet, so a move-then-rollback never drops a live
+ // connection.
+ ver.Store("v2")
+ a.sweepOnce()
+ if a.entryCount() != 1 {
+ t.Fatal("cycled inside the grace window — a transient repoint would drop a live connection")
+ }
+
+ // Past the grace: gracefully cycle (stop → final checkpoint → lease release →
+ // asleep). The reconnecting client/sidecar would wake it onto v2.
+ clk.advance(convergeGrace + time.Second)
+ a.sweepOnce()
+ waitFor(t, "convergence cycle", func() bool { return a.entryCount() == 0 })
+ if !b.lastProc().isStopped() {
+ t.Fatal("stale instance's process was not stopped")
+ }
+ if !b.lastRep().isClosed() {
+ t.Fatal("convergence cycle skipped the final checkpoint / lease release")
+ }
+
+ // Re-wake against the now-current symlink: spawnVersion tracks v2, so it
+ // never cycles again — no thrash.
+ release, err = a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if err != nil {
+ t.Fatalf("re-wake after convergence: %v", err)
+ }
+ release()
+ clk.advance(3 * convergeGrace)
+ a.sweepOnce()
+ if a.entryCount() != 1 {
+ t.Fatal("cycled again after already converging onto the current build")
+ }
+}
+
+// Two awake children go stale at once (a fleet-wide ship). One sweep cycles at
+// most one — a staggered convergence, never a simultaneous blip of every space.
+func TestActivatorConvergenceRateLimitsPerSweep(t *testing.T) {
+ a, b, clk, _ := testActivator(t, time.Hour)
+ var ver atomic.Value
+ ver.Store("v1")
+ a.liveVer = func(string) (string, error) { return ver.Load().(string), nil }
+ b.execVerFn = func() string { return ver.Load().(string) }
+
+ for _, h := range []string{"one.example.com", "two.example.com"} {
+ release, err := a.beginRequest(context.Background(), testRoute(h))
+ if err != nil {
+ t.Fatalf("beginRequest %s: %v", h, err)
+ }
+ release()
+ }
+ if a.entryCount() != 2 {
+ t.Fatalf("want 2 awake instances, got %d", a.entryCount())
+ }
+
+ ver.Store("v2") // ship under both
+ a.sweepOnce() // starts the grace clock for both
+ clk.advance(convergeGrace + time.Second)
+
+ a.sweepOnce() // both are past grace, but only one may cycle this tick
+ waitFor(t, "first convergence cycle", func() bool { return a.entryCount() == 1 })
+ // A moment later, the sweep takes the second.
+ a.sweepOnce()
+ waitFor(t, "second convergence cycle", func() bool { return a.entryCount() == 0 })
+}
+
+// The feature's whole premise: converge a space that NEVER reaches inflight==0
+// (an open WebSocket, or a bot sidecar's persistent connection), which
+// idle-hibernate can never touch. Unlike idle-hibernate, convergence fires with
+// a request in flight — the graceful stop drains it exactly as a restart would.
+func TestActivatorConvergesWithRequestInFlight(t *testing.T) {
+ a, b, clk, _ := testActivator(t, time.Hour)
+ var ver atomic.Value
+ ver.Store("v1")
+ a.liveVer = func(string) (string, error) { return ver.Load().(string), nil }
+ b.execVerFn = func() string { return ver.Load().(string) }
+
+ // Hold the request open for the whole test — inflight stays 1, so the idle
+ // path (which needs inflight==0) can never converge this host.
+ release, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if err != nil {
+ t.Fatalf("beginRequest: %v", err)
+ }
+ defer release()
+
+ ver.Store("v2") // ship under the busy space
+ a.sweepOnce() // grace starts; must not cycle a busy space mid-grace
+ if a.entryCount() != 1 {
+ t.Fatal("cycled inside the grace window")
+ }
+ clk.advance(convergeGrace + time.Second)
+ a.sweepOnce()
+ // Cycles despite the in-flight request — idle-hibernate never would.
+ waitFor(t, "busy-space convergence", func() bool { return a.entryCount() == 0 })
+ if !b.lastProc().isStopped() {
+ t.Fatal("busy stale instance's process was not stopped")
+ }
+}
+
+// convergeDecision's state machine, exhaustively: the grace clock starts on
+// first drift, survives a rollback correctly, and a read error never clears
+// accrued grace (it means "unknown", not "converged").
+func TestConvergeDecision(t *testing.T) {
+ base := time.Unix(1_700_000_000, 0)
+ set := base.Add(-40 * time.Second) // a grace clock that started 40s ago
+ errBoom := errors.New("readlink boom")
+ cases := []struct {
+ name string
+ cur string
+ readErr error
+ spawnVer string
+ staleSince time.Time
+ wantCycle bool
+ wantStale time.Time
+ }{
+ {"matches — clears grace", "v1", nil, "v1", set, false, time.Time{}},
+ {"empty cur — no data, clears", "", nil, "v1", set, false, time.Time{}},
+ {"empty spawnVer — unknown, clears", "v2", nil, "", set, false, time.Time{}},
+ {"first drift — starts grace, no cycle", "v2", nil, "v1", time.Time{}, false, base},
+ {"drift within grace — holds, no cycle", "v2", nil, "v1", base.Add(-30 * time.Second), false, base.Add(-30 * time.Second)},
+ {"drift past grace — cycles", "v2", nil, "v1", base.Add(-convergeGrace - time.Second), true, base.Add(-convergeGrace - time.Second)},
+ {"read error — preserves accrued grace", "", errBoom, "v1", set, false, set},
+ {"read error before any drift — stays zero", "", errBoom, "v1", time.Time{}, false, time.Time{}},
+ {"rollback to spawn — clears grace", "v1", nil, "v1", base.Add(-convergeGrace - time.Second), false, time.Time{}},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ cycle, next := convergeDecision(c.cur, c.readErr, c.spawnVer, c.staleSince, base, convergeGrace)
+ if cycle != c.wantCycle {
+ t.Errorf("cycle = %v, want %v", cycle, c.wantCycle)
+ }
+ if !next.Equal(c.wantStale) {
+ t.Errorf("nextStale = %v, want %v", next, c.wantStale)
+ }
+ })
+ }
+}
+
// A lost lease (failed renewal) stands the instance down immediately — no
// waiting for idle: someone else may own the S3 prefix now.
func TestActivatorLeaseLostStandsDown(t *testing.T) {
diff --git a/docs/awake-instance-convergence.md b/docs/awake-instance-convergence.md
new file mode 100644
index 0000000..91918df
--- /dev/null
+++ b/docs/awake-instance-convergence.md
@@ -0,0 +1,177 @@
+# 🤖 Awake-instance convergence: diagnosis + fix
+
+**Status: IMPLEMENTED (sweep-driven).** This documents a production gap — awake
+hibernate-managed instances kept serving their old binary after a release ships
+— and the fix that closed it. The fix landed as a fourth staleness case in the
+activator's existing idle sweep (`activator.go`, `sweepOnce`); it needed **no
+new router control channel**, no updater changes, and no change to the deploy
+path. The sections below keep the original diagnosis, then describe what was
+built and why it differs from the first proposal.
+
+## The symptom
+
+After `release-server.sh ship` (or the hourly `lchat update run`) repoints an
+instance's `live/<host>` symlink to a new binary:
+
+- `lchat instances` / `lchat update status` show the **new** version.
+- But `/api/version` over the instance's socket returns the **old** build.
+
+Hibernating instances converge on their next wake; **awake** ones (anything
+with an open WebSocket or recent traffic — notably every bot-sidecar-bearing
+space, which never sleeps) serve the stale binary indefinitely. The reporting
+surfaces all read the symlink target, so the fleet *looks* converged while it
+isn't. (Confirmed live 2026-07-13: after promoting a release, `family`,
+`test`, and `team` each served a different older build than the symlink named.)
+
+## Why — the exact mechanism
+
+An awake hibernate-flagged instance is a **child process of the router**, not a
+`systemd` unit (`activator.go`: `activatorSvc.entries[host].proc`, an
+`*exec.Cmd` via `childProc`). It's spawned once by `s3Backend.start`, which
+execs `liveLink(host)` = `/opt/lchat/live/<host>` → `versions/<ver>/lchat`. **A
+running child never re-reads the symlink** — that's the root of the gap.
+
+The update path (`updater.go updatePass`) has two branches:
+
+- **Unit-managed hosts** repoint the symlink *and* `systemctl restart` the unit
+ (`deployHostVersion` in `control.go`) — so they converge immediately.
+- **Hibernating hosts** (`updater.go` phase 2) do **only** `pointLive` — a pure
+ symlink repoint, no signal to the running process. The code comment is
+ explicit: *"a currently-AWAKE child keeps its old binary until it next
+ sleeps (a running process doesn't follow the symlink)."*
+
+`update status` (`runUpdateStatus`) and `lchat instances` both build their
+"current" column from `liveVersion(host)` (`os.Readlink`), never from the
+child's `/api/version` — hence the false "converged" reading.
+
+### What already exists (and what doesn't)
+
+- **#778** added the whole hibernating-repoint phase, `hibernatingPrefs`
+ (parked-DB read), WAL-ownership repair, and the narrow `setVersions`.
+- **#774** added parked-DB pref reads to `update status`.
+- The "compare symlinks, never restart awake children" behaviour is
+ `updater.go`'s symlink comparison + `deploy`'s hibernating branch — not a
+ separate function.
+
+**What does NOT exist anywhere:** a primitive that cycles an *awake* activator
+child onto the new symlink. `lchat deploy <host> --version` also only repoints
+for hibernating hosts (`control.go`, the `if rt.Hibernate` branch is a pure
+`pointLive`). The only graceful cycle today is the heavyweight manual
+`hibernateOff` → update → `hibernateOn` flip documented in
+`docs/server-releases.md`.
+
+## Why awake children aren't restarted (the design tension)
+
+This is deliberate, for two real reasons:
+
+1. **Dropped connections.** An instance is kept awake *by* in-flight work,
+ including open WebSockets (idle = zero in-flight AND elapsed idle window,
+ `activator.go`). Cycling an awake child cuts live WS/requests.
+2. **The single-writer S3 lease.** Each wake acquires a per-host Litestream
+ lease; a naive external `pkill` races the lease and can 502 within the
+ router's dial-retry window (the sweep only reaps every `Idle/4`, ≤30s).
+
+So "never converges" is the current cost of "never drop a live connection."
+
+## The safe primitive that already exists
+
+The activator's own stop **is** graceful: `childProc.stop` sends `SIGTERM`
+(the instance's 10s drain), `SIGKILL` only on overstay; `beginStopLocked` →
+`stop` takes the final S3 checkpoint, releases the lease, stops the sidecar
+first, and deletes the entry — leaving the host **asleep**. The next request or
+due-wake re-execs `liveLink(host)`, which now resolves to the **new** binary.
+So a respawn-onto-new-symlink path exists; it's just **lazy (next request), not
+triggered by a ship.**
+
+## The fix as built (smallest safe change)
+
+The convergence check is a **fourth staleness case in the sweep** the activator
+already runs (`sweepOnce`, every `Idle/4`, capped 30s). The sweep already reaps
+awake children for three reasons — lost lease, dead child, idle — each via the
+graceful `beginStopLocked`. Convergence is the fourth:
+
+1. **Record the spawned build.** The backend's `start` reads `liveVersion(host)`
+ **immediately before `cmd.Start()`** and returns it, so `spawnVersion` names
+ the build the kernel actually resolved the symlink to — not a value re-read
+ later in `wake()`, which a ship landing during the up-to-30s health wait
+ could make disagree with the running binary. A read error leaves it empty,
+ which disables the check for that entry (fail-safe: never cycle on a version
+ we couldn't read).
+2. **Notice the drift, then wait out a grace.** Each sweep compares the live
+ symlink against `spawnVersion` (the pure `convergeDecision` helper holds this
+ state machine, so it's unit-tested directly). On first divergence it stamps
+ `staleSince`; only once the drift has persisted `convergeGrace` (90s) does it
+ cycle. The grace means a symlink that moved then rolled straight back (a bad
+ promote reverted) never drops a connection, and a briefly-busy space gets a
+ chance to idle-hibernate — which converges for free — first. A transient
+ symlink-read error is treated as "unknown" — it neither cycles nor resets the
+ accrued grace.
+3. **Cycle gracefully, one per tick.** Past the grace it calls the same
+ `beginStopLocked` the other three cases use: sidecar stopped first, SIGTERM
+ drain, final S3 checkpoint, lease released, entry deleted → **asleep**. A
+ subsequent **member request** — or the **due-time index** for a space with a
+ scheduled charter — then wakes it, re-execing `liveLink(host)` = the new
+ binary. (The sidecar itself is SIGKILLed during the stop and can't self-wake;
+ a bot space with no members present and no scheduled work simply stays asleep
+ until someone arrives, which is correct hibernation.) At most
+ `maxConvergeCyclesPerSweep` (1) per tick, so a fleet-wide ship staggers across
+ sweeps instead of blipping every space at once.
+
+Unlike idle-hibernation (which only fires at `inflight == 0`), this cycle can
+fire with a request in flight — that's deliberate, since the always-awake spaces
+it targets never reach `inflight == 0`. The graceful stop drains in-flight HTTP
+just as `systemctl restart` does, so the only thing cut is a request still
+running past the instance's ~10s drain (a large upload) — identical to a unit
+restart landing mid-request, not a new failure mode.
+
+No repoint-site call, no control channel: the updater/deploy still just
+`pointLive`, and the router's own sweep picks up the drift within ≤30s. This
+**reuses** the graceful stop and the lazy wake-re-execs-the-symlink path — it
+extends the existing convergence machinery rather than adding a new one.
+
+### Exclusions (all automatic — no special-casing)
+
+- **Pinned canaries.** The updater never repoints a pinned host's symlink, so
+ `spawnVersion` keeps matching and the check never fires. Honouring the pin
+ needs no code here — it falls out of "cycle only when the symlink moved."
+- **Unit-managed (non-hibernating) hosts.** Not activator children — they have
+ no `entries[host]`, so the sweep never sees them; they converge via
+ `systemctl restart` as before.
+- **Bot-sidecar spaces are the PRIMARY target.** They never idle, so the idle
+ case never converges them; this cycle does, and the sidecar is stopped-first
+ and re-spawned on wake, so it's sidecar-safe. This automates exactly what the
+ manual `hibernate --off`/`--on` flip did by hand.
+
+## Why this shape, not the first proposal
+
+The original proposal (kept in git history) added a per-host `cycle` verb to a
+new router control socket and gated on `inflight == 0`. Both were dropped:
+
+- **No control channel.** A push from the repoint site would have added the
+ first per-host *command* into the live router — new attack surface on the
+ process fronting every space. The sweep already wakes hosts on a timer, so a
+ ≤30s lazy pull needs none of that. The convergence latency (seconds to a
+ couple of minutes) is irrelevant given the 72h supply-chain bake window.
+- **Grace window, not `inflight == 0`.** An `inflight == 0` gate would leave the
+ *primary* target — always-awake bot-sidecar spaces, whose persistent
+ WebSocket keeps `inflight > 0` forever — stale indefinitely, defeating the
+ point. Cycling them requires a brief connection drop, which the graceful
+ SIGTERM drain + the router's `actStopping` connection-shed makes clean:
+ clients reconnect onto the new binary, exactly as they do across a normal
+ unit `systemctl restart`. The grace window (plus one-per-tick) is what keeps
+ it gentle — a space that goes quiet converges via idle-hibernate first, and
+ only a persistently-awake space is force-cycled, once, per ship.
+
+This is a *latency/observability* fix, not a correctness emergency: the bake
+window holds releases 72h and the lazy next-wake path already converged
+sleeping spaces. The gap was the always-awake spaces and the dishonest `status`
+column — both now closed (see below).
+
+## Companion: honest `status` (already shipped)
+
+`lchat update status --probe` reads each instance's `/api/version` over its
+socket and flags an awake-stale instance (⚠) where the old symlink-only column
+read "current". That shipped first as a small, safe, independent step; this
+sweep fix removes the underlying staleness the probe surfaces. The manual
+`lchat hibernate <host> --off` → update → `--on` flip still works for forcing an
+immediate converge, but is no longer needed for routine ships.
diff --git a/updater.go b/updater.go
index ff04903..4bf2ee7 100644
--- a/updater.go
+++ b/updater.go
@@ -816,6 +816,7 @@ func statusPrefs(rt Route) (updatePrefs, error) {
func runUpdateStatus(args []string) {
fs := flag.NewFlagSet("update status", flag.ExitOnError)
registryPath := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry SQLite path")
+ probe := fs.Bool("probe", false, "also read each instance's socket /api/version — the build it's ACTUALLY serving, which an awake hibernating instance can lag behind its symlink (docs/awake-instance-convergence.md)")
_ = fs.Parse(reorderFlagsFirst(fs, args))
reg, err := openRegistry(*registryPath)
@@ -829,7 +830,12 @@ func runUpdateStatus(args []string) {
m, merr := fetchReleaseManifest(releaseManifestURL)
bake := loadFirstSeen()
- fmt.Printf("%-34s %-8s %-8s %-12s %s\n", "HOST", "CHANNEL", "AUTO", "CURRENT", "AVAILABLE")
+ if *probe {
+ fmt.Printf("%-34s %-8s %-8s %-12s %-12s %s\n", "HOST", "CHANNEL", "AUTO", "SYMLINK", "SERVING", "AVAILABLE")
+ } else {
+ fmt.Printf("%-34s %-8s %-8s %-12s %s\n", "HOST", "CHANNEL", "AUTO", "CURRENT", "AVAILABLE")
+ }
+ staleServing := false
for _, rt := range routes {
if rt.Kind != "instance" {
continue
@@ -874,7 +880,24 @@ func runUpdateStatus(args []string) {
}
}
}
- fmt.Printf("%-34s %-8s %-8s %-12s %s\n", rt.Host, channel, auto, cur, avail)
+ if *probe {
+ // The build the process is actually serving (vs. the symlink `cur`).
+ serving, ok := probeVersion(rt.Socket)
+ if !ok {
+ serving = "asleep" // socket didn't answer — hibernating, converges on wake
+ } else if serving != cur {
+ serving += " ⚠" // awake but stale: symlink moved, process didn't
+ staleServing = true
+ }
+ fmt.Printf("%-34s %-8s %-8s %-12s %-12s %s\n", rt.Host, channel, auto, cur, serving, avail)
+ } else {
+ fmt.Printf("%-34s %-8s %-8s %-12s %s\n", rt.Host, channel, auto, cur, avail)
+ }
+ }
+ if staleServing {
+ fmt.Println("\n⚠ = awake but serving an older build than its symlink names (a running process doesn't follow the")
+ fmt.Println(" symlink). Converges on next sleep/wake, or cycle now with: lchat hibernate <host> --off && … --on")
+ fmt.Println(" (docs/awake-instance-convergence.md).")
}
if merr != nil {
fmt.Printf("(no manifest: %v)\n", merr)
diff --git a/versions.go b/versions.go
index 49818e3..22201ef 100644
--- a/versions.go
+++ b/versions.go
@@ -405,6 +405,26 @@ func waitHealthyVersion(socket, wantBuild string, timeout time.Duration) error {
return fmt.Errorf("instance didn't report build %q within %s: %v", wantBuild, timeout, last)
}
+// probeVersion reads the build the process behind a socket is ACTUALLY serving
+// (its /api/version), not the symlink target. This is how `update status
+// --probe` catches an awake hibernating instance that repointed its symlink but
+// still runs the old binary (docs/awake-instance-convergence.md). "" + ok=false
+// when the socket doesn't answer (asleep, or no such instance).
+func probeVersion(socket string) (build string, ok bool) {
+ resp, err := unixHTTPClient(socket).Get("http://lchat/api/version")
+ if err != nil {
+ return "", false
+ }
+ defer resp.Body.Close()
+ var body struct {
+ Build string `json:"build"`
+ }
+ if json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&body) != nil {
+ return "", false
+ }
+ return body.Build, body.Build != ""
+}
+
// unixHTTPClient dials a unix socket over HTTP — the router↔instance transport,
// reused by the health checks.
func unixHTTPClient(socket string) *http.Client {
diff --git a/versions_test.go b/versions_test.go
index a78242a..93005e5 100644
--- a/versions_test.go
+++ b/versions_test.go
@@ -1,6 +1,9 @@
package main
import (
+ "io"
+ "net"
+ "net/http"
"os"
"path/filepath"
"testing"
@@ -125,3 +128,34 @@ func TestLiveVersionMissing(t *testing.T) {
t.Errorf("liveVersion(missing) = %q, want empty", got)
}
}
+
+// probeVersion reads the build a socket actually serves — the honest half of
+// `update status --probe` (docs/awake-instance-convergence.md). A tiny HTTP
+// server over a real unix socket stands in for a running instance.
+func TestProbeVersion(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "inst.sock")
+ ln, err := net.Listen("unix", sock)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/api/version" {
+ io.WriteString(w, `{"build":"abc1234","version":"deadbeef"}`)
+ return
+ }
+ http.NotFound(w, r)
+ })}
+ go srv.Serve(ln)
+ defer srv.Close()
+
+ build, ok := probeVersion(sock)
+ if !ok || build != "abc1234" {
+ t.Fatalf("probeVersion served socket: build=%q ok=%v, want abc1234/true", build, ok)
+ }
+
+ // A socket that doesn't answer → ok=false (asleep / no instance), never a panic.
+ if _, ok := probeVersion(filepath.Join(dir, "nope.sock")); ok {
+ t.Fatal("probeVersion of a dead socket returned ok=true")
+ }
+}