diff --git a/src/shared/tmux/constants.ts b/src/shared/tmux/constants.ts index 5299d3964..71205a886 100644 --- a/src/shared/tmux/constants.ts +++ b/src/shared/tmux/constants.ts @@ -1,11 +1,15 @@ // Polling interval for background session status checks export const POLL_INTERVAL_BACKGROUND_MS = 2000 -// Maximum idle time before session considered stale -export const SESSION_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes +// Long-running subagent work can legitimately stay open for a while. +// The tmux-subagent stability fixes raised this guard from 10 minutes after +// polling closed active panes during long tasks. +export const SESSION_TIMEOUT_MS = 60 * 60 * 1000 // 60 minutes -// Grace period for missing session before cleanup -export const SESSION_MISSING_GRACE_MS = 6000 // 6 seconds +// Status queries can transiently miss live sessions under load. +// The tmux-subagent stability fixes raised this guard from 6 seconds after +// false missing detections closed healthy panes. +export const SESSION_MISSING_GRACE_MS = 30 * 1000 // 30 seconds // Session readiness polling config export const SESSION_READY_POLL_INTERVAL_MS = 500 diff --git a/src/shared/tmux/index.ts b/src/shared/tmux/index.ts index a86723661..b523bf642 100644 --- a/src/shared/tmux/index.ts +++ b/src/shared/tmux/index.ts @@ -1,3 +1,4 @@ export * from "./types" export * from "./constants" +export * from "./runner" export * from "./tmux-utils" diff --git a/src/shared/tmux/runner.test.ts b/src/shared/tmux/runner.test.ts new file mode 100644 index 000000000..9832c99b2 --- /dev/null +++ b/src/shared/tmux/runner.test.ts @@ -0,0 +1,127 @@ +/// + +import { afterAll, describe, expect, test } from "bun:test" +import { randomUUID } from "node:crypto" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +import { runTmuxCommand } from "./runner" + +const temporaryDirectories: string[] = [] + +async function createTemporaryDirectory(): Promise { + const directoryPath = await fs.mkdtemp(path.join(os.tmpdir(), "tmux-runner-")) + temporaryDirectories.push(directoryPath) + return directoryPath +} + +async function readInvocationCount(counterFilePath: string): Promise { + const count = await fs.readFile(counterFilePath, "utf8") + return Number.parseInt(count, 10) +} + +afterAll(async () => { + for (const directoryPath of temporaryDirectories) { + await fs.rm(directoryPath, { recursive: true, force: true }) + } +}) + +describe("runTmuxCommand", () => { + test("#given command exits 0 with stdout #when run #then success true, output and stdout equal trimmed value, stderr empty", async () => { + // given + const commandArguments = ["-c", "printf '%s\\n' '%42'"] + + // when + const result = await runTmuxCommand("sh", commandArguments) + + // then + expect(result).toEqual({ + success: true, + output: "%42", + stdout: "%42", + stderr: "", + exitCode: 0, + }) + }) + + test("#given command exits 1 with stderr #when run #then success false, stderr populated", async () => { + // given + const commandArguments = ["-c", "printf '%s\\n' 'some error' >&2; exit 1"] + + // when + const result = await runTmuxCommand("sh", commandArguments) + + // then + expect(result.success).toBe(false) + expect(result.stderr).toBe("some error") + expect(result.exitCode).toBe(1) + }) + + test("#given retry=2 and first exit nonzero #when run #then calls spawn twice before returning failure", async () => { + // given + const temporaryDirectory = await createTemporaryDirectory() + const counterFilePath = path.join(temporaryDirectory, `${randomUUID()}.count`) + const commandScript = `counter_file="$1"; count=0; if [ -f "$counter_file" ]; then count=$(cat "$counter_file"); fi; count=$((count + 1)); printf '%s' "$count" > "$counter_file"; printf '%s\\n' 'temporary error' >&2; exit 1` + + // when + const result = await runTmuxCommand("sh", ["-c", commandScript, "sh", counterFilePath], { retry: 2 }) + + // then + expect(result.success).toBe(false) + expect(result.stderr).toBe("temporary error") + expect(await readInvocationCount(counterFilePath)).toBe(3) + }) + + test("#given retry=2 and stderr contains 'can't find pane' #when run #then does NOT retry", async () => { + // given + const temporaryDirectory = await createTemporaryDirectory() + const counterFilePath = path.join(temporaryDirectory, `${randomUUID()}.count`) + const commandScript = `counter_file="$1"; count=0; if [ -f "$counter_file" ]; then count=$(cat "$counter_file"); fi; count=$((count + 1)); printf '%s' "$count" > "$counter_file"; printf '%s\\n' "can't find pane: %1" >&2; exit 1` + + // when + const result = await runTmuxCommand("sh", ["-c", commandScript, "sh", counterFilePath], { retry: 2 }) + + // then + expect(result.success).toBe(false) + expect(result.stderr).toContain("can't find pane") + expect(await readInvocationCount(counterFilePath)).toBe(1) + }) + + test("#given timeoutMs=50 and command sleeps 500ms #when run #then returns timeout failure", async () => { + // given + const commandArguments = ["-c", "sleep 0.5"] + + // when + const result = await runTmuxCommand("sh", commandArguments, { timeoutMs: 50 }) + + // then + expect(result.success).toBe(false) + expect(result.exitCode).toBe(-1) + expect(result.stderr).toContain("timeout") + }) + + test("#given stdout contains trailing newline #when run #then output is trimmed", async () => { + // given + const commandArguments = ["-c", "printf '%s\\n\\n' '%7'"] + + // when + const result = await runTmuxCommand("sh", commandArguments) + + // then + expect(result.output).toBe("%7") + expect(result.stdout).toBe("%7") + }) + + test("#given backward-compat consumer destructures {success, output} #when result returned #then both fields present and correct", async () => { + // given + const commandArguments = ["-c", "printf '%s\\n' '%9'"] + + // when + const { success, output } = await runTmuxCommand("sh", commandArguments) + + // then + expect(success).toBe(true) + expect(output).toBe("%9") + }) +}) diff --git a/src/shared/tmux/runner.ts b/src/shared/tmux/runner.ts new file mode 100644 index 000000000..0290a96aa --- /dev/null +++ b/src/shared/tmux/runner.ts @@ -0,0 +1,107 @@ +import { spawn } from "bun" + +type RunTmuxOptions = { + retry?: number + timeoutMs?: number +} + +export type TmuxCommandResult = { + success: boolean + output: string + stdout: string + stderr: string + exitCode: number +} + +const TERMINAL_TMUX_ERROR_PATTERN = /can't find (pane|session)/i + +function createTmuxCommandResult(stdout: string, stderr: string, exitCode: number): TmuxCommandResult { + return { + success: exitCode === 0, + output: stdout, + stdout, + stderr, + exitCode, + } +} + +function isTerminalTmuxError(stderr: string): boolean { + return TERMINAL_TMUX_ERROR_PATTERN.test(stderr) +} + +/** + * Detect whether we are running inside cmux (cmux omo). + * When cmux-omo sets up the environment it injects a tmux shim and sets + * CMUX_SOCKET_PATH / TMUX. If detected, redirect tmux commands to + * `cmux __tmux-compat` so they become native cmux splits instead of + * failing because there is no real tmux server running. + */ +function resolveTmuxExecutable(tmuxPath: string): string[] { + const inCmux = Boolean(process.env.CMUX_SOCKET_PATH) || + process.env.TMUX?.includes("cmuxterm") === true + if (inCmux) { + return ["cmux", "__tmux-compat"] + } + return [tmuxPath] +} + +async function runTmuxCommandOnce(tmuxPath: string, args: Array, timeoutMs?: number): Promise { + const abortController = new AbortController() + const subprocess = spawn([...resolveTmuxExecutable(tmuxPath), ...args], { + stdout: "pipe", + stderr: "pipe", + signal: abortController.signal, + }) + const stdoutPromise = new Response(subprocess.stdout).text() + const stderrPromise = new Response(subprocess.stderr).text() + + let timeoutId: ReturnType | undefined + + try { + const exitCodeOrTimeout = timeoutMs === undefined + ? await subprocess.exited + : await Promise.race(([ + subprocess.exited, + new Promise<"timeout">((resolve) => { + timeoutId = setTimeout(() => { + abortController.abort() + resolve("timeout") + }, timeoutMs) + }), + ])) + + if (exitCodeOrTimeout === "timeout") { + void subprocess.exited.catch(() => undefined) + void stdoutPromise.catch(() => "") + void stderrPromise.catch(() => "") + return createTmuxCommandResult("", "timeout", -1) + } + + const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]) + return createTmuxCommandResult(stdout.trim(), stderr.trim(), exitCodeOrTimeout) + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + } +} + +export async function runTmuxCommand(tmuxPath: string, args: string[], options: RunTmuxOptions = {}): Promise { + const retryCount = Math.max(0, options.retry ?? 0) + let lastResult = createTmuxCommandResult("", "", 1) + + for (let attempt = 0; attempt <= retryCount; attempt += 1) { + const result = await runTmuxCommandOnce(tmuxPath, args, options.timeoutMs) + lastResult = result + + if (result.exitCode === 0) { + return result + } + + if (attempt === retryCount || isTerminalTmuxError(result.stderr)) { + return result + } + } + + return lastResult +}