feat(team-mode): add sweep-stale-team-sessions with tests
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user