refactor(tmux): export sweepTmuxSessionsWith and add runtime tests

This commit is contained in:
YeonGyu-Kim
2026-04-28 10:44:56 +09:00
parent 96161cc0e6
commit c9e544a667
4 changed files with 189 additions and 36 deletions
+1 -1
View File
@@ -12,6 +12,6 @@ export { replaceTmuxPane } from "./tmux-utils/pane-replace"
export { spawnTmuxWindow } from "./tmux-utils/window-spawn" export { spawnTmuxWindow } from "./tmux-utils/window-spawn"
export { spawnTmuxSession, getIsolatedSessionName } from "./tmux-utils/session-spawn" export { spawnTmuxSession, getIsolatedSessionName } from "./tmux-utils/session-spawn"
export { killTmuxSessionIfExists } from "./tmux-utils/session-kill" 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" export { applyLayout, enforceMainPaneWidth } from "./tmux-utils/layout"
@@ -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<TmuxCommandResult> => ({
success: true,
output: "",
stdout: "",
stderr: "",
exitCode: 0,
}))
const killTmuxSessionIfExistsMock = mock(async (): Promise<boolean> => true)
const isInsideTmuxMock = mock((): boolean => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "sh")
const logMock = mock(() => undefined)
async function loadSweepStaleOmoAgentSessions(): Promise<typeof import("./stale-session-sweep").sweepStaleOmoAgentSessions> {
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)
})
})
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, mock } from "bun:test" 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 = { type SweepFixture = {
deps: SweepDeps deps: SweepDeps
@@ -152,3 +152,25 @@ describe("sweepStaleOmoAgentSessionsWith", () => {
expect(fixture.killed).toEqual(["omo-agents-99999"]) 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"])
})
})
@@ -1,5 +1,13 @@
const STALE_SESSION_PATTERN = /^omo-agents-(\d+)$/ 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 { function isProcessAlive(pid: number): boolean {
try { try {
process.kill(pid, 0) process.kill(pid, 0)
@@ -10,36 +18,48 @@ function isProcessAlive(pid: number): boolean {
} }
} }
async function listOmoAgentSessionsViaTmux(tmux: string): Promise<string[]> { async function listTmuxSessionsViaTmux(tmux: string): Promise<string[]> {
const { spawn } = await import("./spawn-process") const { runTmuxCommand } = await import("../runner")
const proc = spawn([tmux, "list-sessions", "-F", "#{session_name}"], { const result = await runTmuxCommand(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,
])
if (exitCode !== 0) { if (result.exitCode !== 0) {
return [] return []
} }
return stdout return result.output
.split("\n") .split("\n")
.map((line) => line.trim()) .map((line) => line.trim())
.filter((name) => STALE_SESSION_PATTERN.test(name)) .filter((name) => name.length > 0)
} }
export type SweepDeps = { export type SweepTmuxSessionsDeps = {
isInsideTmux: () => boolean isInsideTmux: () => boolean
getTmuxPath: () => Promise<string | null | undefined> getTmuxPath: () => Promise<string | null | undefined>
listCandidateSessions: (tmux: string) => Promise<string[]> listCandidateSessions: (tmux: string) => Promise<string[]>
killSession: (sessionName: string) => Promise<boolean> killSession: (sessionName: string) => Promise<boolean>
log: (message: string, payload?: unknown) => void
}
export type SweepDeps = SweepTmuxSessionsDeps & {
processAlive: (pid: number) => boolean processAlive: (pid: number) => boolean
currentPid: number 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<SweepDeps> { async function buildRuntimeDeps(): Promise<SweepDeps> {
@@ -53,7 +73,7 @@ async function buildRuntimeDeps(): Promise<SweepDeps> {
return { return {
isInsideTmux, isInsideTmux,
getTmuxPath, getTmuxPath,
listCandidateSessions: listOmoAgentSessionsViaTmux, listCandidateSessions: listTmuxSessionsViaTmux,
killSession: killTmuxSessionIfExists, killSession: killTmuxSessionIfExists,
processAlive: isProcessAlive, processAlive: isProcessAlive,
currentPid: process.pid, currentPid: process.pid,
@@ -61,36 +81,75 @@ async function buildRuntimeDeps(): Promise<SweepDeps> {
} }
} }
export async function sweepStaleOmoAgentSessionsWith(deps: SweepDeps): Promise<number> { export async function sweepTmuxSessionsWith(
deps: SweepTmuxSessionsDeps,
options: SweepTmuxSessionsOptions,
): Promise<string[]> {
if (!deps.isInsideTmux()) { if (!deps.isInsideTmux()) {
return 0 return []
} }
const tmux = await deps.getTmuxPath() const tmux = await deps.getTmuxPath()
if (!tmux) { if (!tmux) {
return 0 return []
} }
const candidateSessions = await deps.listCandidateSessions(tmux) let candidateSessions: string[]
let killedCount = 0
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) { for (const sessionName of candidateSessions) {
const pidMatch = sessionName.match(STALE_SESSION_PATTERN) if (!matchesSweepOptions(sessionName, options)) {
if (!pidMatch) continue continue
}
const pid = Number.parseInt(pidMatch[1], 10) try {
if (!Number.isFinite(pid)) continue const killed = await deps.killSession(sessionName)
if (pid === deps.currentPid) continue if (killed) {
if (deps.processAlive(pid)) continue killedSessionNames.push(sessionName)
}
deps.log("[sweepStaleOmoAgentSessions] killing stale session", { sessionName, deadPid: pid }) } catch (error) {
const killed = await deps.killSession(sessionName) deps.log("[sweepTmuxSessionsWith] failed to kill stale session", {
if (killed) { error: getErrorMessage(error),
killedCount += 1 sessionName,
})
} }
} }
return killedCount return killedSessionNames
}
export async function sweepStaleOmoAgentSessionsWith(deps: SweepDeps): Promise<number> {
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<number> { export async function sweepStaleOmoAgentSessions(): Promise<number> {