server: bot sidecars track their instance's app version (#418)
Bots exec the control binary (/opt/lchat/lchat), decoupled from the versioned per-instance binary, so a deploy/update moved the instance but left its sidecar on stale code — the app gained a feature (e.g. member apps) the bot never learned it could do. Make the sidecar run the SAME versioned binary as its instance: - lchat-bot@.service execs /opt/lchat/live/%i (the per-host live symlink the instance runs), so the bot's build == the app's build by construction. Reusing one symlink is the only design that stays correct when instances run different versions (e.g. a canary). - `bot run` with no positional args now sources LCHAT_BASE + the LCHAT_*_SESSION paths from the EnvironmentFile, so the template is fully generic — a full trio or an Eleven-only box both work with no per-host args. - deployHostVersion (shared by `deploy` and `update run`) and runRollback try-restart lchat-bot@<host> after the instance is healthy, keeping the bot in lock-step. try-restart is a no-op when no sidecar is active, so instances without a bot are untouched; a bot hiccup only logs and never fails an otherwise-healthy instance deploy. (A failed deploy that rolls back leaves the version unchanged, so it deliberately does not restart the bot.) - The Go sidecar gets MemoryDenyWriteExecute (the Node/V8-JIT caveat that forced it off is gone). Note: sync fires only on a version *change* — `deploy <host> --version <same>` won't resync an already-current instance's stale bot. Existing boxes may carry a hand-written lchat-bot@<host>.service.d/override.conf pinning the old binary; a drop-in ExecStart wins over the template, so remove it (+ daemon-reload + restart) once for the versioned template to take effect. Migration runbook in docs/instance-router.md § Upgrading an existing box to version-tracked bots.
Co-authored-by: Claude <noreply@anthropic.com>
8 files changed,
+108
−41
bootstrap_test.go+4 −2botcmd.go+13 −1control.go+14 −0deploy/README.md+16 −14deploy/systemd/lchat-bot@.service+9 −7docs/instance-router.md+39 −15updater_test.go+12 −2versions.go+1 −0
diff --git a/bootstrap_test.go b/bootstrap_test.go| index 6e1fad8..34d1521 100644 |
| --- a/bootstrap_test.go |
| +++ b/bootstrap_test.go |
| @@ -18,8 +18,10 @@ func TestEmbeddedUnits(t *testing.T) { |
| if !strings.Contains(unitInstance, "ExecStart=/opt/lchat/live/%i --socket /run/lchat/%i.sock --db /data/%i.db") { |
| t.Error("instance unit missing its versioned ExecStart line") |
| } |
| - if !strings.Contains(unitBot, "ExecStart=/usr/bin/node /opt/lchat/agent/agent.mjs") { |
| - t.Error("bot unit missing its node ExecStart line") |
| + // The bot execs the SAME per-instance live symlink as its instance, so its |
| + // binary tracks the app version (deployHostVersion restarts it on a move). |
| + if !strings.Contains(unitBot, "ExecStart=/opt/lchat/live/%i bot run") { |
| + t.Error("bot unit missing its versioned `bot run` ExecStart line") |
| } |
| if !strings.Contains(unitBot, "EnvironmentFile=-/data/%i-bot.env") { |
| t.Error("bot unit missing its EnvironmentFile") |
diff --git a/botcmd.go b/botcmd.go| index 013f470..39d2c50 100644 |
| --- a/botcmd.go |
| +++ b/botcmd.go |
| @@ -111,7 +111,19 @@ func runBotRun(args []string) { |
| } |
| paths := fs.Args() |
| if len(paths) == 0 { |
| - paths = []string{envOr("LCHAT_SESSION", "session.json")} |
| + // No sessions on the command line: source them from the env the sidecar |
| + // unit provides, running whichever identities are set — so the generic |
| + // lchat-bot@.service template needs no per-host args, and a full trio or an |
| + // Eleven-only box both work. Falls back to a lone session.json for a |
| + // hand-run bot with nothing set. |
| + for _, ev := range []string{"LCHAT_SESSION", "LCHAT_ELEVEN_SESSION", "LCHAT_ANNOUNCER_SESSION"} { |
| + if p := strings.TrimSpace(os.Getenv(ev)); p != "" { |
| + paths = append(paths, p) |
| + } |
| + } |
| + if len(paths) == 0 { |
| + paths = []string{"session.json"} |
| + } |
| } |
| for _, path := range paths { |
| sess, err := bot.LoadSession(path) |
diff --git a/control.go b/control.go| index cc5d912..8fa36d0 100644 |
| --- a/control.go |
| +++ b/control.go |
| @@ -6,6 +6,7 @@ import ( |
| "crypto/tls" |
| "flag" |
| "fmt" |
| + "log" |
| "net" |
| "net/http" |
| "os" |
| @@ -609,9 +610,22 @@ func deployHostVersion(reg *Registry, rt *Route, unit, ver string) (changed bool |
| if err := reg.upsert(*rt); err != nil { |
| return true, fmt.Errorf("record version: %v", err) |
| } |
| + restartBotSidecar(host) |
| return true, nil |
| } |
| +// restartBotSidecar best-effort re-execs a host's bot sidecar so it picks up the |
| +// same versioned binary the instance now runs — the bot execs /opt/lchat/live/<host> |
| +// too (deploy/systemd/lchat-bot@.service), so restarting it after the live symlink |
| +// moves keeps the bot's code in lock-step with the app. `try-restart` is a no-op when |
| +// no sidecar is active, so instances without a bot are untouched; a bot hiccup only |
| +// logs and never fails an otherwise-healthy instance deploy. |
| +func restartBotSidecar(host string) { |
| + if err := systemctl("try-restart", "lchat-bot@"+host); err != nil { |
| + log.Printf("deploy: bot sidecar restart for %s failed (instance is healthy on the new version): %v", host, err) |
| + } |
| +} |
| + |
| // acquireDeployLock serialises everything that runs the repoint/restart/ |
| // health-check dance — deploy, rollback, and the timer-fired `update run` — |
| // across processes, so an auto-update can never interleave with an operator's |
diff --git a/deploy/README.md b/deploy/README.md| index d74a9fa..7ed1f4e 100644 |
| --- a/deploy/README.md |
| +++ b/deploy/README.md |
| @@ -28,26 +28,28 @@ instead: `scp deploy/systemd/*.service` to `/etc/systemd/system/`, drop a |
| ## Bots (sidecars) |
| -The sidecar is **Node**, not the Go binary (it reuses `web/static/crypto.js` for |
| -E2E — that's why it's Node), so a box that runs bots needs a Node runtime and the |
| -`agent/` code + its deps: |
| +The sidecar is the **same Go binary** as the instance — `lchat bot run` — so a box |
| +that runs the server runs its bots with **no extra install** (no Node runtime). The |
| +`lchat-bot@.service` template execs the instance's own versioned live symlink |
| +(`ExecStart=/opt/lchat/live/%i bot run`), so a bot always runs the **same version |
| +as its app**, and `deploy`/`update` restart it automatically when they move the |
| +instance (`docs/instance-router.md § Bots`, § Auto-updating). |
| ```sh |
| -# one-time per box: Node 24 + the sidecar source + deps |
| -ssh root@<box> 'curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && apt-get install -y nodejs' |
| -rsync -a agent web/static root@<box>:/opt/lchat/ # sidecar imports ../web/static/crypto.js |
| -ssh root@<box> 'cd /opt/lchat/agent && npm ci' |
| - |
| # per instance: mint the three agent invites + start the sidecar |
| # (API key via env, not a flag, so it doesn't leak into ps/shell history) |
| ssh root@<box> 'ANTHROPIC_API_KEY=sk-ant-... /opt/lchat/lchat add-bot chat.example.com' |
| ``` |
| `add-bot` mints an agent invite per identity (Claude / Eleven / Announcer) via the |
| -instance's admin API — running `agent/mint-invite.mjs`, which reuses `crypto.js` so |
| -the derived invite matches what the sidecar provisions with — writes |
| -`/data/<host>-bot.env` (invites + session paths + optional `ANTHROPIC_API_KEY`), |
| -and starts `lchat-bot@<host>`. Omit `ANTHROPIC_API_KEY` for a deterministic-only bot |
| -(Eleven still welcomes new members + answers slash commands). Update a bot's code |
| -with `systemctl restart lchat-bot@<host>` (it backfills on reconnect). Start the |
| +instance's admin API, writes `/data/<host>-bot.env` (invites + `LCHAT_BASE` + |
| +`LCHAT_*_SESSION` paths + optional `ANTHROPIC_API_KEY`), and enables |
| +`lchat-bot@<host>` — which reads all of that from the env file, so there's nothing |
| +per-host to configure in the unit. Omit `ANTHROPIC_API_KEY` for a deterministic-only |
| +bot (Eleven still welcomes new members + answers slash commands). Start the |
| **instance before** the sidecar so Eleven baselines the roster correctly. |
| + |
| +> The Node reference sidecar (`agent/`) is kept on purpose as a runnable example; |
| +> self-hosters who prefer it still need a Node runtime + `npm ci` in `/opt/lchat/agent` |
| +> and point the unit's `ExecStart` at `node /opt/lchat/agent/agent.mjs`. The default |
| +> deployment is Go. |
diff --git a/deploy/systemd/lchat-bot@.service b/deploy/systemd/lchat-bot@.service| index fa35a3c..713b637 100644 |
| --- a/deploy/systemd/lchat-bot@.service |
| +++ b/deploy/systemd/lchat-bot@.service |
| @@ -7,11 +7,14 @@ Wants=network-online.target |
| # add-bot writes /data/%i-bot.env (0600) with LCHAT_BASE, the three agent invites, |
| # session paths, and (optionally) ANTHROPIC_API_KEY. |
| EnvironmentFile=-/data/%i-bot.env |
| -WorkingDirectory=/opt/lchat/agent |
| -# The sidecar is Node (not the static Go binary): the box needs a node runtime and |
| -# `npm ci` in /opt/lchat/agent (see deploy/README.md). It connects to its instance |
| -# over the public URL, backfills on reconnect, so a deploy is just a restart. |
| -ExecStart=/usr/bin/node /opt/lchat/agent/agent.mjs |
| +# The sidecar is the SAME versioned Go binary as its instance: exec the per-host |
| +# live symlink (/opt/lchat/live/%i → versions/<ver>/lchat) that lchat@%i.service |
| +# runs, so the bot's code stays in lock-step with the app by construction. `bot run` |
| +# with no positional args reads LCHAT_BASE + the LCHAT_*_SESSION paths from the |
| +# EnvironmentFile above, running whichever identities are set (a full trio or an |
| +# Eleven-only box both work). A deploy/update repoints that symlink and restarts this |
| +# unit (deployHostVersion → restartBotSidecar), so the bot always tracks the version. |
| +ExecStart=/opt/lchat/live/%i bot run |
| Restart=always |
| RestartSec=2 |
| @@ -41,8 +44,7 @@ RestrictNamespaces=yes |
| RestrictRealtime=yes |
| RestrictSUIDSGID=yes |
| LockPersonality=yes |
| -# NOTE: no MemoryDenyWriteExecute — V8's JIT maps writable+executable memory, so |
| -# enabling it (as the Go units do) would crash Node on boot. |
| +MemoryDenyWriteExecute=yes |
| SystemCallFilter=@system-service |
| SystemCallErrorNumber=EPERM |
| SystemCallArchitectures=native |
diff --git a/docs/instance-router.md b/docs/instance-router.md| index 09e90ac..3f58c42 100644 |
| --- a/docs/instance-router.md |
| +++ b/docs/instance-router.md |
| @@ -224,9 +224,16 @@ gets the conversational default (command floor + model; badges AI when |
| `ANTHROPIC_API_KEY` is set). A custom bot drops a `<session>.recipe.json` |
| alongside its session (`docs/bot-recipes.md`) — behaviour as data, no code. |
| -Supervise it as **one `lchat-bot@<host>.service` per instance**, restarted on |
| -deploy. Because the sidecar backfills on reconnect, a bot deploy is a plain |
| -restart — no socket-swap, no drain. One wrinkle: it reaches its instance at the |
| +Supervise it as **one `lchat-bot@<host>.service` per instance**. The unit execs |
| +the *same* per-instance live symlink as the instance itself |
| +(`ExecStart=/opt/lchat/live/%i bot run`), so the bot runs the **same version as its |
| +app by construction** — and `deploy`/`update` restart the bot right after they |
| +repoint that symlink and health-check the instance (`deployHostVersion → |
| +restartBotSidecar`), so the sidecar's code never lags the server. `bot run` with no |
| +positional args reads `LCHAT_BASE` + the `LCHAT_*_SESSION` paths from the unit's |
| +EnvironmentFile, so the template is fully generic — a full trio or an Eleven-only |
| +box both work with no per-host args. Because the sidecar backfills on reconnect, |
| +that restart is a plain reconnect — no socket-swap, no drain. One wrinkle: it reaches its instance at the |
| public URL through the router, so the box must resolve that public hostname **to |
| itself**. Pin every hostname the box hosts to loopback in `/etc/hosts` |
| (`127.0.0.1 chat.example.com …`) so on-box clients — sidecars, and an instance's |
| @@ -247,16 +254,11 @@ The session files are **byte-compatible** (`internal/bot/session.go` matches the |
| Node `session.json` shape), so a running Node sidecar swaps to Go in place with |
| no re-provisioning and no lost identity: |
| -1. Point the unit's `ExecStart` at the Go sidecar, keeping the same session |
| - directory and env file: |
| - ```ini |
| - ExecStart=/opt/lchat/lchat bot run -base https://%i \ |
| - /data/%i-bot/session.json \ |
| - /data/%i-bot/eleven-session.json \ |
| - /data/%i-bot/announcer-session.json |
| - ``` |
| - (The env file still carries `ANTHROPIC_API_KEY` and, if the bot does |
| - connected-services, `LCHAT_CONNECT_BROKER_URL`/`_TOKEN`.) |
| +1. The shipped `lchat-bot@.service` template already execs the versioned Go |
| + sidecar — `ExecStart=/opt/lchat/live/%i bot run` — sourcing `LCHAT_BASE` and |
| + the `LCHAT_*_SESSION` paths (and `ANTHROPIC_API_KEY`, and |
| + `LCHAT_CONNECT_BROKER_URL`/`_TOKEN` for connected-services) from |
| + `/data/%i-bot.env`. Nothing per-host to write; `add-bot` just enables it. |
| 2. `systemctl daemon-reload && systemctl restart lchat-bot@<host>`. The bot |
| reconnects, re-declares its kind, and backfills — members see no gap. |
| 3. The Go sidecar keeps the Node sidecar's `.state.json` (announce memory), so |
| @@ -266,6 +268,26 @@ Once **no** unit on the box still `ExecStart`s Node, the Node runtime and the |
| `agent/node_modules` install can be removed entirely — the box becomes a pure |
| Go deployment. |
| +### Upgrading an existing box to version-tracked bots |
| + |
| +Older boxes may carry a **hand-written drop-in** pinning the bot to a fixed |
| +binary — `/etc/systemd/system/lchat-bot@<host>.service.d/override.conf` with |
| +`ExecStart=/opt/lchat/lchat bot run …` (or an even older canary path). A drop-in |
| +`ExecStart` **wins over the template**, so those bots keep running one frozen |
| +build and never track their instance. Migrate each once: |
| + |
| +```sh |
| +# drop the pin so the versioned template (live/%i) takes effect, and reinstall |
| +# the current template in case the box predates it |
| +rm -f /etc/systemd/system/lchat-bot@<host>.service.d/override.conf |
| +scp deploy/systemd/lchat-bot@.service root@<box>:/etc/systemd/system/ # or re-run `lchat bootstrap` |
| +systemctl daemon-reload |
| +systemctl restart lchat-bot@<host> |
| +``` |
| + |
| +After this the bot execs `/opt/lchat/live/<host>` and every subsequent |
| +`deploy`/`update` keeps it in lock-step with the app automatically. |
| + |
| ## The registry (a small control DB) |
| The router owns one SQLite table. Status lives here (not a flat file) so an |
| @@ -431,7 +453,8 @@ described in `docs/server-releases.md`: |
| binary 404s, which reads as the defaults: auto-update on, stable channel), |
| then per instance: downloads + sha256-checks + stages the channel's binary |
| and applies it with the exact `deploy --version` dance (repoint → restart → |
| - health-check → auto-rollback). A version that fails its health check on one |
| + health-check → auto-rollback → **restart the host's bot sidecar** so it tracks |
| + the new version too). A version that fails its health check on one |
| host is not tried on the rest that pass ("poisoned" for the run); other |
| per-host failures log and continue, so one unreachable instance never |
| strands the fleet. |
| @@ -474,7 +497,8 @@ described in `docs/server-releases.md`: |
| give resource isolation; `Restart=always` gives crash isolation. Bump |
| `LimitNOFILE` (instances × WS connections × SQLite files). |
| - `lchat-bot@.service` — the optional per-instance sidecar (`lchat bot run`; see |
| - *Bots*). `Restart=always`; a deploy is just a restart (it backfills on reconnect). |
| + *Bots*). `Restart=always`; execs the same `live/%i` binary as the instance, so a |
| + deploy/update restarts it onto the matching version (it backfills on reconnect). |
| ## Hardening |
diff --git a/updater_test.go b/updater_test.go| index aa01bdb..4c82e61 100644 |
| --- a/updater_test.go |
| +++ b/updater_test.go |
| @@ -221,9 +221,19 @@ func TestUpdatePassAppliesPerPrefs(t *testing.T) { |
| t.Fatalf("h2 registry = %s", rt.Version) |
| } |
| - // Exactly two restarts: h1 and h4. |
| + // Two instances moved (h1, h4); each restarts the instance and then its bot |
| + // sidecar so the bot tracks the new version (restartBotSidecar). Four calls, |
| + // no more — h2 (download-only) and h3 (downloads off) never restart. |
| got := strings.Join(*calls, "; ") |
| - if !strings.Contains(got, "restart lchat@h1.example.com") || !strings.Contains(got, "restart lchat@h4.example.com") || len(*calls) != 2 { |
| + for _, want := range []string{ |
| + "restart lchat@h1.example.com", "try-restart lchat-bot@h1.example.com", |
| + "restart lchat@h4.example.com", "try-restart lchat-bot@h4.example.com", |
| + } { |
| + if !strings.Contains(got, want) { |
| + t.Fatalf("missing %q in systemctl calls = %v", want, *calls) |
| + } |
| + } |
| + if len(*calls) != 4 { |
| t.Fatalf("systemctl calls = %v", *calls) |
| } |
diff --git a/versions.go b/versions.go| index d3981fc..6f71cc2 100644 |
| --- a/versions.go |
| +++ b/versions.go |
| @@ -330,6 +330,7 @@ func runRollback(args []string) { |
| if err := reg.upsert(*rt); err != nil { |
| die("rollback %s: record: %v", host, err) |
| } |
| + restartBotSidecar(host) |
| fmt.Printf("rolled back %s to %s\n", host, prev) |
| } |
| } |