feat(team-mode): add sweep-stale-team-sessions with tests

This commit is contained in:
YeonGyu-Kim
2026-04-28 10:46:37 +09:00
parent 2b8096fc06
commit f78b1e62d7
2 changed files with 259 additions and 0 deletions
@@ -0,0 +1,183 @@
/// <reference types="bun-types" />
import { describe, expect, it, mock } from "bun:test"
import {
sweepStaleTeamSessionsWith,
type TeamSweepDeps,
} from "./sweep-stale-team-sessions"
type LoggedMessage = {
message: string
meta?: unknown
}
type SweepFixture = {
deps: TeamSweepDeps
killedSessionNames: string[]
loggedMessages: LoggedMessage[]
killSessionMock: ReturnType<typeof mock>
listCandidatesMock: ReturnType<typeof mock>
}
function createFixture(candidateSessions: string[]): SweepFixture {
const killedSessionNames: string[] = []
const loggedMessages: LoggedMessage[] = []
const listCandidatesMock = mock(async (): Promise<string[]> => [...candidateSessions])
const killSessionMock = mock(async (sessionName: string): Promise<void> => {
killedSessionNames.push(sessionName)
})
const deps: TeamSweepDeps = {
listCandidates: listCandidatesMock,
killSession: killSessionMock,
log: (message, meta) => {
loggedMessages.push({ message, meta })
},
}
return {
deps,
killedSessionNames,
loggedMessages,
killSessionMock,
listCandidatesMock,
}
}
describe("sweepStaleTeamSessionsWith", () => {
it("#given candidates with mix of active and stale #when sweep #then kills only sessions whose runId is not in active set", async () => {
// given
const fixture = createFixture([
"omo-team-11111111-1111-1111-1111-111111111111",
"omo-team-22222222-2222-2222-2222-222222222222",
"omo-team-33333333-3333-3333-3333-333333333333",
"main",
"omo-agents-123",
])
const activeTeamRunIds = new Set(["11111111-1111-1111-1111-111111111111"])
// when
const result = await sweepStaleTeamSessionsWith(activeTeamRunIds, fixture.deps)
// then
expect(fixture.killSessionMock).toHaveBeenCalledTimes(2)
expect(fixture.killedSessionNames).toEqual([
"omo-team-22222222-2222-2222-2222-222222222222",
"omo-team-33333333-3333-3333-3333-333333333333",
])
expect(result).toEqual([
"omo-team-22222222-2222-2222-2222-222222222222",
"omo-team-33333333-3333-3333-3333-333333333333",
])
})
it("#given all candidates active #when sweep #then kills none", async () => {
// given
const fixture = createFixture([
"omo-team-11111111-1111-1111-1111-111111111111",
"omo-team-22222222-2222-2222-2222-222222222222",
])
const activeTeamRunIds = new Set([
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
])
// when
const result = await sweepStaleTeamSessionsWith(activeTeamRunIds, fixture.deps)
// then
expect(fixture.killSessionMock).toHaveBeenCalledTimes(0)
expect(result).toEqual([])
})
it("#given listCandidates throws #when sweep #then returns empty array and logs", async () => {
// given
const fixture = createFixture([])
const activeTeamRunIds = new Set<string>()
fixture.listCandidatesMock.mockImplementation(async (): Promise<string[]> => {
throw new Error("list failed")
})
// when
const result = await sweepStaleTeamSessionsWith(activeTeamRunIds, fixture.deps)
// then
expect(result).toEqual([])
expect(fixture.loggedMessages).toHaveLength(1)
expect(fixture.loggedMessages[0]?.message).toContain("failed to list")
})
it("#given killSession throws for one #when sweep #then continues and returns only successful kills", async () => {
// given
const fixture = createFixture([
"omo-team-11111111-1111-1111-1111-111111111111",
"omo-team-22222222-2222-2222-2222-222222222222",
"omo-team-33333333-3333-3333-3333-333333333333",
])
const activeTeamRunIds = new Set<string>()
fixture.killSessionMock.mockImplementation(async (sessionName: string): Promise<void> => {
if (sessionName === "omo-team-22222222-2222-2222-2222-222222222222") {
throw new Error("kill failed")
}
fixture.killedSessionNames.push(sessionName)
})
// when
const result = await sweepStaleTeamSessionsWith(activeTeamRunIds, fixture.deps)
// then
expect(fixture.killSessionMock).toHaveBeenCalledTimes(3)
expect(fixture.killedSessionNames).toEqual([
"omo-team-11111111-1111-1111-1111-111111111111",
"omo-team-33333333-3333-3333-3333-333333333333",
])
expect(fixture.loggedMessages).toHaveLength(1)
expect(result).toEqual([
"omo-team-11111111-1111-1111-1111-111111111111",
"omo-team-33333333-3333-3333-3333-333333333333",
])
})
it("#given candidate name is 'omo-team-' with empty suffix #when sweep #then skipped", async () => {
// given
const fixture = createFixture(["omo-team-", "omo-team-11111111-1111-1111-1111-111111111111"])
const activeTeamRunIds = new Set<string>()
// when
const result = await sweepStaleTeamSessionsWith(activeTeamRunIds, fixture.deps)
// then
expect(fixture.killedSessionNames).toEqual(["omo-team-11111111-1111-1111-1111-111111111111"])
expect(result).toEqual(["omo-team-11111111-1111-1111-1111-111111111111"])
})
it("#given new caller-session topology rolled out with no omo-team-<uuid> candidates #when sweep runs #then the result is empty and killSession is never called", async () => {
// given
const fixture = createFixture(["main", "dev-shell", "project-grid"])
const activeTeamRunIds = new Set<string>(["still-active-run"])
// when
const result = await sweepStaleTeamSessionsWith(activeTeamRunIds, fixture.deps)
// then
expect(result).toEqual([])
expect(fixture.killSessionMock).toHaveBeenCalledTimes(0)
})
it("#given a user tmux session named like a project hash #when sweep runs #then it is preserved because only UUID-backed team sessions are eligible", async () => {
// given
const fixture = createFixture(["main", "omo-team-de2e", "dev-shell"])
const activeTeamRunIds = new Set<string>()
// when
const result = await sweepStaleTeamSessionsWith(activeTeamRunIds, fixture.deps)
// then
expect(fixture.killSessionMock).toHaveBeenCalledTimes(0)
expect(fixture.killedSessionNames).toEqual([])
expect(result).toEqual([])
})
})
@@ -0,0 +1,76 @@
const UUID_V4ISH_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
export const TEAM_SESSION_PATTERN = new RegExp(`^omo-team-(${UUID_V4ISH_PATTERN})$`)
export type TeamSweepDeps = {
listCandidates: () => Promise<string[]>
killSession: (name: string) => Promise<void>
log: (message: string, payload?: unknown) => void
}
async function listTeamSessionsViaTmux(tmuxPath: string): Promise<string[]> {
const { runTmuxCommand } = await import("../../../shared/tmux")
const result = await runTmuxCommand(tmuxPath, ["list-sessions", "-F", "#{session_name}"])
if (!result.success) {
return []
}
return result.output
.split("\n")
.map((line) => line.trim())
.filter((sessionName) => sessionName.length > 0)
}
async function killTeamSessionViaTmux(tmuxPath: string, sessionName: string): Promise<void> {
const { runTmuxCommand } = await import("../../../shared/tmux")
const result = await runTmuxCommand(tmuxPath, ["kill-session", "-t", sessionName])
if (!result.success) {
throw new Error(`Failed to kill tmux session: ${sessionName}`)
}
}
export async function sweepStaleTeamSessionsWith(
activeTeamRunIds: ReadonlySet<string>,
deps: TeamSweepDeps,
): Promise<string[]> {
const { sweepTmuxSessionsWith } = await import("../../../shared/tmux")
return sweepTmuxSessionsWith(
{
isInsideTmux: () => true,
getTmuxPath: async () => "tmux",
listCandidateSessions: async () => deps.listCandidates(),
killSession: async (sessionName) => {
await deps.killSession(sessionName)
return true
},
log: deps.log,
},
{
predicate: (sessionName) => {
const teamRunId = sessionName.match(TEAM_SESSION_PATTERN)?.[1]
return teamRunId !== undefined && teamRunId.length > 0 && !activeTeamRunIds.has(teamRunId)
},
},
)
}
export async function sweepStaleTeamSessions(activeTeamRunIds: ReadonlySet<string>): Promise<string[]> {
const [{ log }, { getTmuxPath }] = await Promise.all([
import("../../../shared"),
import("../../../tools/interactive-bash/tmux-path-resolver"),
])
const tmuxPath = await getTmuxPath()
if (!tmuxPath) {
return []
}
return sweepStaleTeamSessionsWith(activeTeamRunIds, {
listCandidates: () => listTeamSessionsViaTmux(tmuxPath),
killSession: (sessionName) => killTeamSessionViaTmux(tmuxPath, sessionName),
log,
})
}