From 5fd73f612e056e3bee5ed9fca34bce1fb5e74145 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 06:55:51 +0000 Subject: [PATCH] fix(herd): allow-set-title is tmux 3.5, and a tmux that rejects it gets a retry CI on ubuntu-latest (tmux 3.4) answered `invalid option: allow-set-title` to every session start after #502, so no herd member could start there and the tmux-backed tests went red. The option arrived in 3.5, not 3.4. tmuxCanPinTitle now requires 3.5. And because a version string is a guess about a binary, startSession also learns the answer the hard way: when tmux rejects the pin it kills the half-made session (tmux stops at the command it rejects, after the session exists), remembers "no" for the rest of the process, and starts the member with the plan an older tmux understands. A wrong guess costs one retry, never a member. Verified: the full suite locally (tmux 3.6); the survival, tile and pinned- title suites in a node:22 container with the fake-runner fallback; and, as a baseline, main's own herd.mjs fails the same three tmux tests on Debian's 3.3a, so that older crash is pre-existing and separate. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014HMV1QDcVY1xiaWWrZQM4S --- src/herd.mjs | 32 +++++++++++++++++----- test/herd-pinned-title.test.mjs | 47 ++++++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/herd.mjs b/src/herd.mjs index 66344048..040308a2 100644 --- a/src/herd.mjs +++ b/src/herd.mjs @@ -266,8 +266,12 @@ export function tmux(args, { runner = spawnSync, env = process.env, encoding = " let pinTitleSupport; /** * Can this tmux stop an application from renaming its pane? `allow-set-title` - * arrived in 3.4. Asked once per process: the answer is a property of the - * binary, and a start plan is built for every member. + * arrived in 3.5 — not 3.4, which is what Ubuntu 24.04 (and so every + * ubuntu-latest runner) ships, and which answers `invalid option` and starts + * nothing. Asked once per process: the answer is a property of the binary, + * and a start plan is built for every member. startSession also learns the + * answer the hard way (below), so a wrong guess here costs one retry, never + * a member. */ export function tmuxCanPinTitle({ runner = spawnSync, force = false } = {}) { if (pinTitleSupport !== undefined && !force) return pinTitleSupport; @@ -275,10 +279,13 @@ export function tmuxCanPinTitle({ runner = spawnSync, force = false } = {}) { try { version = String(runner("tmux", ["-V"], { encoding: "utf8" })?.stdout || ""); } catch { version = ""; } const m = /tmux\s+(?:next-)?(\d+)\.(\d+)/.exec(version); - pinTitleSupport = Boolean(m) && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 4)); + pinTitleSupport = Boolean(m) && (Number(m[1]) > 3 || (Number(m[1]) === 3 && Number(m[2]) >= 5)); return pinTitleSupport; } +/** tmux's own words for "I do not have that option". */ +const UNKNOWN_PIN_OPTION = /invalid option:\s*allow-set-title/i; + /** * The shell-command tmux runs for a session. * @@ -346,9 +353,10 @@ export function tmuxStartPlan({ name, cwd, command, pinTitle = true }) { // writes "1 awaiting input · claude agents" the moment it is up) would // otherwise overwrite the handle through OSC 0/2, and a member whose pane // no longer answers to its name reads as `gone` on the roster while it is - // sitting there waiting for you. tmux 3.4+; on an older tmux the option is - // unknown and the whole invocation would fail, so startSession asks - // tmuxCanPinTitle first and an old tmux keeps today's behaviour. + // sitting there waiting for you. tmux 3.5+; on an older tmux the option is + // unknown and the whole invocation fails, so startSession asks + // tmuxCanPinTitle first, retries without it if tmux still objects, and an + // old tmux keeps today's behaviour. ...(pinTitle ? [";", "set-option", "-w", "-t", name, "allow-set-title", "off"] : []), ]; } @@ -668,7 +676,17 @@ export function startSession({ if (substrate === "tmux") { const command = sessionCommand({ bin, args, stripEnv, setEnv: sessionEnv(name) }); - const started = tmux(tmuxStartPlan({ name, cwd, command, pinTitle: tmuxCanPinTitle({ runner }) }), { runner, env }); + let started = tmux(tmuxStartPlan({ name, cwd, command, pinTitle: tmuxCanPinTitle({ runner }) }), { runner, env }); + if (!started.ok && UNKNOWN_PIN_OPTION.test(started.stderr || "")) { + // The version guess was wrong (a distro build, a version string we did + // not expect). tmux runs the plan's commands in order and stops at the + // one it rejects, so the session may already exist; the retry must not + // see it as "already running". Remember the answer for the rest of the + // process, then start it the way an older tmux can. + pinTitleSupport = false; + tmux(["kill-session", "-t", name], { runner, env }); + started = tmux(tmuxStartPlan({ name, cwd, command, pinTitle: false }), { runner, env }); + } if (!started.ok) { return { ok: false, error: new Error(started.stderr.trim() || started.error?.message || "tmux could not start the session") }; } diff --git a/test/herd-pinned-title.test.mjs b/test/herd-pinned-title.test.mjs index 57d7f741..8181ba8a 100644 --- a/test/herd-pinned-title.test.mjs +++ b/test/herd-pinned-title.test.mjs @@ -2,10 +2,14 @@ // its own terminal title would take it away. Seen live: Claude Code writes // "1 awaiting input · claude agents" the moment it is up, and the member read // as `gone` while it sat there waiting for a prompt. +// +// And the other half, seen on CI: `allow-set-title` is a tmux 3.5 option. +// Ubuntu 24.04 ships 3.4, answers "invalid option", and starts nothing — so a +// wrong guess about the version must cost a retry, never a member. import test from "node:test"; import assert from "node:assert/strict"; -import { tmuxCanPinTitle, tmuxStartPlan } from "../src/herd.mjs"; +import { startSession, tmuxCanPinTitle, tmuxStartPlan } from "../src/herd.mjs"; test("the start plan pins the pane title so an engine cannot rename itself off the roster", () => { const plan = tmuxStartPlan({ name: "api", cwd: "/x", command: "exec claude" }); @@ -24,12 +28,47 @@ test("an old tmux gets the plan without the option it does not know", () => { assert.ok(plan.includes("-T"), "the title is still set — it is the handle for the pane on every tmux"); }); -test("tmuxCanPinTitle reads the version: 3.4 or newer", () => { +test("tmuxCanPinTitle reads the version: 3.5 or newer, because 3.4 rejects the option", () => { const at = (v) => tmuxCanPinTitle({ force: true, runner: () => ({ stdout: `tmux ${v}\n` }) }); assert.equal(at("3.6"), true); - assert.equal(at("3.4"), true); + assert.equal(at("3.5"), true); + assert.equal(at("3.5a"), true); + assert.equal(at("3.4"), false, "ubuntu 24.04's tmux — the one every ubuntu-latest runner has"); assert.equal(at("3.3a"), false); assert.equal(at("2.9"), false); - assert.equal(at("next-3.5"), true); + assert.equal(at("next-3.6"), true); assert.equal(tmuxCanPinTitle({ force: true, runner: () => { throw new Error("no tmux"); } }), false); }); + +/** + * A tmux that claims a version the guard trusts but does not know the option: + * `-V` says 3.5, `new-session … allow-set-title` fails the way tmux 3.4 does. + */ +function tmuxThatRejectsThePin() { + const calls = []; + const runner = (bin, args) => { + calls.push(args); + if (args[0] === "-V") return { status: 0, stdout: "tmux 3.5\n", stderr: "" }; + if (args.includes("new-session") && args.includes("allow-set-title")) { + return { status: 1, stdout: "", stderr: "invalid option: allow-set-title\n" }; + } + // list-panes (liveNames), kill-session, the retried new-session: fine. + return { status: 0, stdout: "", stderr: "" }; + }; + return { calls, runner }; +} + +test("a tmux that rejects allow-set-title gets one retry without it, and the member starts", () => { + tmuxCanPinTitle({ force: true, runner: () => ({ stdout: "tmux 3.5\n" }) }); + const { calls, runner } = tmuxThatRejectsThePin(); + const result = startSession({ name: "api", engine: "claude", bin: "/bin/true", cwd: "/tmp", substrate: "tmux", runner }); + assert.equal(result.ok, true, "the member must start on a tmux that does not know the option"); + const starts = calls.filter((a) => a.includes("new-session")); + assert.equal(starts.length, 2, "one attempt with the pin, one without"); + assert.ok(starts[0].includes("allow-set-title")); + assert.ok(!starts[1].includes("allow-set-title")); + // tmux stops at the command it rejects, after the session exists. + const killed = calls.find((a) => a[0] !== "-V" && a.includes("kill-session")); + assert.ok(killed, "the half-made session is removed before the retry"); + assert.equal(tmuxCanPinTitle({ runner }), false, "the answer is remembered for the rest of the process"); +});