From c9e544a66787e3a2616814c16e33407799f2c0d1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:44:56 +0900 Subject: [PATCH] refactor(tmux): export sweepTmuxSessionsWith and add runtime tests --- src/shared/tmux/tmux-utils.ts | 2 +- .../stale-session-sweep-runtime.test.ts | 72 ++++++++++ .../tmux-utils/stale-session-sweep.test.ts | 24 +++- .../tmux/tmux-utils/stale-session-sweep.ts | 127 +++++++++++++----- 4 files changed, 189 insertions(+), 36 deletions(-) create mode 100644 src/shared/tmux/tmux-utils/stale-session-sweep-runtime.test.ts diff --git a/src/shared/tmux/tmux-utils.ts b/src/shared/tmux/tmux-utils.ts index 6ccdeed31..58f033bc9 100644 --- a/src/shared/tmux/tmux-utils.ts +++ b/src/shared/tmux/tmux-utils.ts @@ -12,6 +12,6 @@ export { replaceTmuxPane } from "./tmux-utils/pane-replace" export { spawnTmuxWindow } from "./tmux-utils/window-spawn" export { spawnTmuxSession, getIsolatedSessionName } from "./tmux-utils/session-spawn" export { killTmuxSessionIfExists } from "./tmux-utils/session-kill" -export { sweepStaleOmoAgentSessions } from "./tmux-utils/stale-session-sweep" +export { sweepStaleOmoAgentSessions, sweepTmuxSessionsWith } from "./tmux-utils/stale-session-sweep" export { applyLayout, enforceMainPaneWidth } from "./tmux-utils/layout" diff --git a/src/shared/tmux/tmux-utils/stale-session-sweep-runtime.test.ts b/src/shared/tmux/tmux-utils/stale-session-sweep-runtime.test.ts new file mode 100644 index 000000000..d57ca0dc3 --- /dev/null +++ b/src/shared/tmux/tmux-utils/stale-session-sweep-runtime.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test" + +import type { TmuxCommandResult } from "../runner" + +const staleSessionSweepSpecifier = import.meta.resolve("./stale-session-sweep") +const environmentSpecifier = import.meta.resolve("./environment") +const loggerSpecifier = import.meta.resolve("../../logger") +const runnerSpecifier = import.meta.resolve("../runner") +const sessionKillSpecifier = import.meta.resolve("./session-kill") +const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver") + +const runTmuxCommandMock = mock(async (): Promise => ({ + success: true, + output: "", + stdout: "", + stderr: "", + exitCode: 0, +})) +const killTmuxSessionIfExistsMock = mock(async (): Promise => true) +const isInsideTmuxMock = mock((): boolean => true) +const getTmuxPathMock = mock(async (): Promise => "sh") +const logMock = mock(() => undefined) + +async function loadSweepStaleOmoAgentSessions(): Promise { + const module = await import(`${staleSessionSweepSpecifier}?test=${crypto.randomUUID()}`) + return module.sweepStaleOmoAgentSessions +} + +function registerModuleMocks(): void { + mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock })) + mock.module(loggerSpecifier, () => ({ log: logMock })) + mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock })) + mock.module(sessionKillSpecifier, () => ({ killTmuxSessionIfExists: killTmuxSessionIfExistsMock })) + mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock })) +} + +describe("sweepStaleOmoAgentSessions runtime runner integration", () => { + beforeEach(() => { + registerModuleMocks() + runTmuxCommandMock.mockClear() + killTmuxSessionIfExistsMock.mockClear() + isInsideTmuxMock.mockClear() + getTmuxPathMock.mockClear() + logMock.mockClear() + + runTmuxCommandMock.mockResolvedValue({ + success: true, + output: "omo-agents-99991\nomo-agents-99992", + stdout: "omo-agents-99991\nomo-agents-99992", + stderr: "", + exitCode: 0, + }) + killTmuxSessionIfExistsMock.mockResolvedValue(true) + isInsideTmuxMock.mockReturnValue(true) + getTmuxPathMock.mockResolvedValue("sh") + }) + + it("#given stale sessions listed by tmux #when sweepStaleOmoAgentSessions called #then delegates list-sessions to shared runner", async () => { + // given + const sweepStaleOmoAgentSessions = await loadSweepStaleOmoAgentSessions() + + // when + const result = await sweepStaleOmoAgentSessions() + + // then + expect(result).toBe(2) + expect(runTmuxCommandMock.mock.calls).toEqual([ + ["sh", ["list-sessions", "-F", "#{session_name}"]], + ]) + expect(killTmuxSessionIfExistsMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/shared/tmux/tmux-utils/stale-session-sweep.test.ts b/src/shared/tmux/tmux-utils/stale-session-sweep.test.ts index 1acc171ed..66b8323de 100644 --- a/src/shared/tmux/tmux-utils/stale-session-sweep.test.ts +++ b/src/shared/tmux/tmux-utils/stale-session-sweep.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, mock } from "bun:test" -import { sweepStaleOmoAgentSessionsWith, type SweepDeps } from "./stale-session-sweep" +import { sweepStaleOmoAgentSessionsWith, sweepTmuxSessionsWith, type SweepDeps } from "./stale-session-sweep" type SweepFixture = { deps: SweepDeps @@ -152,3 +152,25 @@ describe("sweepStaleOmoAgentSessionsWith", () => { expect(fixture.killed).toEqual(["omo-agents-99999"]) }) }) + +describe("sweepTmuxSessionsWith", () => { + let fixture: SweepFixture + + beforeEach(() => { + fixture = createFixture() + }) + + it("#given custom predicate for team sessions #when shared sweep called #then only matching sessions are killed", async () => { + // given + fixture.setCandidates(["omo-team-A", "omo-team-B", "main", "omo-agents-99999"]) + + // when + const result = await sweepTmuxSessionsWith(fixture.deps, { + predicate: (sessionName) => sessionName.startsWith("omo-team-"), + }) + + // then + expect(result).toEqual(["omo-team-A", "omo-team-B"]) + expect(fixture.killed).toEqual(["omo-team-A", "omo-team-B"]) + }) +}) diff --git a/src/shared/tmux/tmux-utils/stale-session-sweep.ts b/src/shared/tmux/tmux-utils/stale-session-sweep.ts index c8b27e938..48b88ad52 100644 --- a/src/shared/tmux/tmux-utils/stale-session-sweep.ts +++ b/src/shared/tmux/tmux-utils/stale-session-sweep.ts @@ -1,5 +1,13 @@ const STALE_SESSION_PATTERN = /^omo-agents-(\d+)$/ +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + + return String(error) +} + function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0) @@ -10,36 +18,48 @@ function isProcessAlive(pid: number): boolean { } } -async function listOmoAgentSessionsViaTmux(tmux: string): Promise { - const { spawn } = await import("./spawn-process") - const proc = spawn([tmux, "list-sessions", "-F", "#{session_name}"], { - stdout: "pipe", - stderr: "pipe", - }) - const [stdout, , exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]) +async function listTmuxSessionsViaTmux(tmux: string): Promise { + const { runTmuxCommand } = await import("../runner") + const result = await runTmuxCommand(tmux, ["list-sessions", "-F", "#{session_name}"]) - if (exitCode !== 0) { + if (result.exitCode !== 0) { return [] } - return stdout + return result.output .split("\n") .map((line) => line.trim()) - .filter((name) => STALE_SESSION_PATTERN.test(name)) + .filter((name) => name.length > 0) } -export type SweepDeps = { +export type SweepTmuxSessionsDeps = { isInsideTmux: () => boolean getTmuxPath: () => Promise listCandidateSessions: (tmux: string) => Promise killSession: (sessionName: string) => Promise + log: (message: string, payload?: unknown) => void +} + +export type SweepDeps = SweepTmuxSessionsDeps & { processAlive: (pid: number) => boolean currentPid: number - log: (message: string, payload?: unknown) => void +} + +export type SweepTmuxSessionsOptions = { + prefix?: string + predicate?: (sessionName: string) => boolean +} + +function matchesSweepOptions(sessionName: string, options: SweepTmuxSessionsOptions): boolean { + if (options.predicate) { + return options.predicate(sessionName) + } + + if (options.prefix) { + return sessionName.startsWith(options.prefix) + } + + return true } async function buildRuntimeDeps(): Promise { @@ -53,7 +73,7 @@ async function buildRuntimeDeps(): Promise { return { isInsideTmux, getTmuxPath, - listCandidateSessions: listOmoAgentSessionsViaTmux, + listCandidateSessions: listTmuxSessionsViaTmux, killSession: killTmuxSessionIfExists, processAlive: isProcessAlive, currentPid: process.pid, @@ -61,36 +81,75 @@ async function buildRuntimeDeps(): Promise { } } -export async function sweepStaleOmoAgentSessionsWith(deps: SweepDeps): Promise { +export async function sweepTmuxSessionsWith( + deps: SweepTmuxSessionsDeps, + options: SweepTmuxSessionsOptions, +): Promise { if (!deps.isInsideTmux()) { - return 0 + return [] } const tmux = await deps.getTmuxPath() if (!tmux) { - return 0 + return [] } - const candidateSessions = await deps.listCandidateSessions(tmux) - let killedCount = 0 + let candidateSessions: string[] + + try { + candidateSessions = await deps.listCandidateSessions(tmux) + } catch (error) { + deps.log("[sweepTmuxSessionsWith] failed to list candidate sessions", { + error: getErrorMessage(error), + }) + return [] + } + + const killedSessionNames: string[] = [] for (const sessionName of candidateSessions) { - const pidMatch = sessionName.match(STALE_SESSION_PATTERN) - if (!pidMatch) continue + if (!matchesSweepOptions(sessionName, options)) { + continue + } - const pid = Number.parseInt(pidMatch[1], 10) - if (!Number.isFinite(pid)) continue - if (pid === deps.currentPid) continue - if (deps.processAlive(pid)) continue - - deps.log("[sweepStaleOmoAgentSessions] killing stale session", { sessionName, deadPid: pid }) - const killed = await deps.killSession(sessionName) - if (killed) { - killedCount += 1 + try { + const killed = await deps.killSession(sessionName) + if (killed) { + killedSessionNames.push(sessionName) + } + } catch (error) { + deps.log("[sweepTmuxSessionsWith] failed to kill stale session", { + error: getErrorMessage(error), + sessionName, + }) } } - return killedCount + return killedSessionNames +} + +export async function sweepStaleOmoAgentSessionsWith(deps: SweepDeps): Promise { + const killedSessionNames = await sweepTmuxSessionsWith(deps, { + predicate: (sessionName) => { + const pidMatch = sessionName.match(STALE_SESSION_PATTERN) + if (!pidMatch) { + return false + } + + const pid = Number.parseInt(pidMatch[1], 10) + if (!Number.isFinite(pid)) { + return false + } + + if (pid === deps.currentPid) { + return false + } + + return !deps.processAlive(pid) + }, + }) + + return killedSessionNames.length } export async function sweepStaleOmoAgentSessions(): Promise {