From f78b1e62d7117489bad8ae1a6dbc47a202edeb39 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:46:37 +0900 Subject: [PATCH] feat(team-mode): add sweep-stale-team-sessions with tests --- .../sweep-stale-team-sessions.test.ts | 183 ++++++++++++++++++ .../sweep-stale-team-sessions.ts | 76 ++++++++ 2 files changed, 259 insertions(+) create mode 100644 src/features/team-mode/team-layout-tmux/sweep-stale-team-sessions.test.ts create mode 100644 src/features/team-mode/team-layout-tmux/sweep-stale-team-sessions.ts diff --git a/src/features/team-mode/team-layout-tmux/sweep-stale-team-sessions.test.ts b/src/features/team-mode/team-layout-tmux/sweep-stale-team-sessions.test.ts new file mode 100644 index 000000000..d79d856db --- /dev/null +++ b/src/features/team-mode/team-layout-tmux/sweep-stale-team-sessions.test.ts @@ -0,0 +1,183 @@ +/// + +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 + listCandidatesMock: ReturnType +} + +function createFixture(candidateSessions: string[]): SweepFixture { + const killedSessionNames: string[] = [] + const loggedMessages: LoggedMessage[] = [] + + const listCandidatesMock = mock(async (): Promise => [...candidateSessions]) + const killSessionMock = mock(async (sessionName: string): Promise => { + 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() + fixture.listCandidatesMock.mockImplementation(async (): Promise => { + 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() + fixture.killSessionMock.mockImplementation(async (sessionName: string): Promise => { + 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() + + // 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- 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(["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() + + // when + const result = await sweepStaleTeamSessionsWith(activeTeamRunIds, fixture.deps) + + // then + expect(fixture.killSessionMock).toHaveBeenCalledTimes(0) + expect(fixture.killedSessionNames).toEqual([]) + expect(result).toEqual([]) + }) +}) diff --git a/src/features/team-mode/team-layout-tmux/sweep-stale-team-sessions.ts b/src/features/team-mode/team-layout-tmux/sweep-stale-team-sessions.ts new file mode 100644 index 000000000..dcb435c7b --- /dev/null +++ b/src/features/team-mode/team-layout-tmux/sweep-stale-team-sessions.ts @@ -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 + killSession: (name: string) => Promise + log: (message: string, payload?: unknown) => void +} + +async function listTeamSessionsViaTmux(tmuxPath: string): Promise { + 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 { + 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, + deps: TeamSweepDeps, +): Promise { + 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): Promise { + 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, + }) +}