feat(tmux): introduce typed tmux command runner abstraction
This commit is contained in:
@@ -1,11 +1,15 @@
|
|||||||
// Polling interval for background session status checks
|
// Polling interval for background session status checks
|
||||||
export const POLL_INTERVAL_BACKGROUND_MS = 2000
|
export const POLL_INTERVAL_BACKGROUND_MS = 2000
|
||||||
|
|
||||||
// Maximum idle time before session considered stale
|
// Long-running subagent work can legitimately stay open for a while.
|
||||||
export const SESSION_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes
|
// 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
|
// Status queries can transiently miss live sessions under load.
|
||||||
export const SESSION_MISSING_GRACE_MS = 6000 // 6 seconds
|
// 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
|
// Session readiness polling config
|
||||||
export const SESSION_READY_POLL_INTERVAL_MS = 500
|
export const SESSION_READY_POLL_INTERVAL_MS = 500
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./types"
|
export * from "./types"
|
||||||
export * from "./constants"
|
export * from "./constants"
|
||||||
|
export * from "./runner"
|
||||||
export * from "./tmux-utils"
|
export * from "./tmux-utils"
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
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<string> {
|
||||||
|
const directoryPath = await fs.mkdtemp(path.join(os.tmpdir(), "tmux-runner-"))
|
||||||
|
temporaryDirectories.push(directoryPath)
|
||||||
|
return directoryPath
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readInvocationCount(counterFilePath: string): Promise<number> {
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string>, timeoutMs?: number): Promise<TmuxCommandResult> {
|
||||||
|
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<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exitCodeOrTimeout = timeoutMs === undefined
|
||||||
|
? await subprocess.exited
|
||||||
|
: await Promise.race<number | "timeout">(([
|
||||||
|
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<TmuxCommandResult> {
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user