refactor(tmux): export sweepTmuxSessionsWith and add runtime tests
This commit is contained in:
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user