feat(server-s3): a member's tmux follows tmux upgrades — tmux-member + Users → Members' tmux
A member's tmux server keeps the version it started with until their last pane closes, but the dashboard asked it with TMUX_BIN, the newest tmux: a 3.8 client on a 3.6 server spun it at 100% CPU (three members needed a manual kill on 2026-10-15). - /opt/agenthub/libexec/tmux-member (install-system, in sudoers): runs a member's tmux call with the binary of THEIR running server (/proc/<pid>/exe, found from the socket in /proc/net/unix), else TMUX_BIN. It runs as the member and execs only a process they own. sudoArgv uses it once installed (linux-accounts readConf), TMUX_BIN until then. - Users → Members' tmux (GET /api/users/tmux): each member server's version beside the installed one, sessions, agents working. An admin restarts one (POST /api/users/:id/tmux-restart) or every idle outdated one (POST /api/users/tmux-restart-idle), with their password again, audited per member; a server that will not stop answers 409 ETMUXSTUCK naming its pid for root. - server-s3-system.test.js: `delete` is a provision action since Delete user, so the "unknown action" case now uses `purge`. Operators: run `sudo agenthub server install-system` once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @file member-tmux.js
|
||||
* @description Server mode, one Linux user per account (S3): each member's
|
||||
* own tmux server as the Users page shows it — the version it runs next to
|
||||
* the installed one (`TMUX_BIN`), how many sessions it holds and whether an
|
||||
* agent of theirs is working — and an admin's restart of it. A tmux server
|
||||
* keeps the version it started with until its last session closes, so after
|
||||
* a tmux upgrade an old server lingers; `tmux-member` (install-system) keeps
|
||||
* every call on it matched to its own binary, and this is how an admin moves
|
||||
* a member onto the new version on purpose. A restart ends every pane of
|
||||
* theirs (their Claude sessions resume from the workspace).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { db } = require("../db");
|
||||
const linux = require("./linux-accounts");
|
||||
const tmux = require("./tmux");
|
||||
|
||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||
|
||||
// The installed tmux's version (`3.8`), re-read at most once a minute.
|
||||
let installed = { at: 0, bin: null, version: null };
|
||||
function installedVersion() {
|
||||
const bin = linux.serverConf()?.tmuxInstalled || null;
|
||||
if (installed.bin === bin && Date.now() - installed.at < 60_000) return installed.version;
|
||||
let version = null;
|
||||
if (bin) {
|
||||
try {
|
||||
version =
|
||||
String(execFileSync(bin, ["-V"], { encoding: "utf8", timeout: 3000, stdio: "pipe" }))
|
||||
.trim()
|
||||
.replace(/^tmux\s+/, "") || null;
|
||||
} catch {
|
||||
/* missing or broken: unknown */
|
||||
}
|
||||
}
|
||||
installed = { at: Date.now(), bin, version };
|
||||
return version;
|
||||
}
|
||||
|
||||
/** How many of `userId`'s workspaces have an agent working right now. */
|
||||
function busyCount(userId) {
|
||||
return db
|
||||
.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ? AND status = 'running'")
|
||||
.get(userId).n;
|
||||
}
|
||||
|
||||
/** One user's row; null for an account with no Linux side. */
|
||||
function rowFor(user, installedV) {
|
||||
const acct = linux.accountFor(user.username);
|
||||
if (!acct) return null;
|
||||
const info = tmux.memberServerInfo(acct);
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
...info,
|
||||
busy: busyCount(user.id),
|
||||
outdated: !!(info.running && info.version && installedV && info.version !== installedV),
|
||||
};
|
||||
}
|
||||
|
||||
/** `{available, installed, matched, users: [...]}` for the Users page. */
|
||||
function overview(users) {
|
||||
if (!linux.s3Enabled()) return { available: false, users: [] };
|
||||
const conf = linux.serverConf();
|
||||
const v = installedVersion();
|
||||
return {
|
||||
available: true,
|
||||
installed: v,
|
||||
// Whether calls on an older server already use its own binary.
|
||||
matched: conf?.bins?.tmux !== conf?.tmuxInstalled,
|
||||
users: users.map((u) => rowFor(u, v)).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart `user`'s tmux server. `{restarted: false}` when none ran; throws
|
||||
* ETMUXSTUCK (with the pid) when it would not stop — root has to kill it.
|
||||
*/
|
||||
function restart(user) {
|
||||
const acct = linux.accountFor(user.username);
|
||||
if (!acct) throw err("ENOS3", "this account has no Linux user (S3 is not installed)");
|
||||
const before = tmux.memberServerInfo(acct);
|
||||
if (!before.running) return { restarted: false, before };
|
||||
try {
|
||||
tmux.killMemberServer(acct);
|
||||
} catch {
|
||||
/* judged by what is left below */
|
||||
}
|
||||
const after = tmux.memberServerInfo(acct);
|
||||
if (after.running) {
|
||||
const pid = before.pid || after.pid;
|
||||
throw err(
|
||||
"ETMUXSTUCK",
|
||||
`${user.username}'s tmux server did not stop — run \`sudo kill -9 ${pid || "<pid>"}\` on the server`
|
||||
);
|
||||
}
|
||||
return { restarted: true, before };
|
||||
}
|
||||
|
||||
module.exports = { overview, restart, installedVersion };
|
||||
Reference in New Issue
Block a user