diff --git a/src/features/team-mode/team-runtime/delete-team-bg-cancel.test.ts b/src/features/team-mode/team-runtime/delete-team-bg-cancel.test.ts new file mode 100644 index 000000000..d29bd0088 --- /dev/null +++ b/src/features/team-mode/team-runtime/delete-team-bg-cancel.test.ts @@ -0,0 +1,84 @@ +/// + +import { afterEach, describe, expect, mock, test } from "bun:test" +import { rm } from "node:fs/promises" + +import type { BackgroundManager } from "../../background-agent/manager" +import { createFixture, updateMemberStatuses } from "./shutdown-test-fixtures" + +const { deleteTeam } = await import("./delete-team") + +describe("deleteTeam cancels only this team's background tasks", () => { + const temporaryDirectories: string[] = [] + + afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => { + await rm(directoryPath, { recursive: true, force: true }) + })) + }) + + test("uses leadSessionId as the getTasksByParentSession key", async () => { + // given + const fixture = await createFixture() + temporaryDirectories.push(fixture.baseDir) + await updateMemberStatuses(fixture.teamRunId, fixture.config, { + "member-a": "shutdown_approved", + "member-b": "shutdown_approved", + }) + + const getTasksByParentSessionMock = mock((sessionID: string) => { + if (sessionID !== "lead-session") return [] + return [ + { id: "team-task-a", sessionID: "session-a", parentMessageID: `team-create:${fixture.teamRunId}:member-a` }, + { id: "team-task-b", sessionID: "session-b", parentMessageID: `team-create:${fixture.teamRunId}:member-b` }, + ] + }) + const cancelTaskMock = mock(async () => true) + const bgMgr = { + getTasksByParentSession: getTasksByParentSessionMock, + cancelTask: cancelTaskMock, + } as unknown as BackgroundManager + + // when + await deleteTeam(fixture.teamRunId, fixture.config, undefined, bgMgr) + + // then + expect(getTasksByParentSessionMock).toHaveBeenCalledTimes(1) + expect(getTasksByParentSessionMock).toHaveBeenCalledWith("lead-session") + expect(cancelTaskMock).toHaveBeenCalledTimes(2) + const firstCall = cancelTaskMock.mock.calls[0] + const secondCall = cancelTaskMock.mock.calls[1] + expect(firstCall?.[0]).toBe("team-task-a") + expect(secondCall?.[0]).toBe("team-task-b") + }) + + test("leaves unrelated sibling tasks on the same lead session alive", async () => { + // given + const fixture = await createFixture() + temporaryDirectories.push(fixture.baseDir) + await updateMemberStatuses(fixture.teamRunId, fixture.config, { + "member-a": "shutdown_approved", + "member-b": "shutdown_approved", + }) + + const getTasksByParentSessionMock = mock(() => [ + { id: "team-task-a", sessionID: "session-a", parentMessageID: `team-create:${fixture.teamRunId}:member-a` }, + { id: "delegate-task-x", sessionID: "session-x", parentMessageID: "delegate-task:plan-refactor" }, + { id: "background-task-y", sessionID: "session-y", parentMessageID: undefined }, + { id: "team-task-other", sessionID: "session-other", parentMessageID: "team-create:other-team-id:member-a" }, + ]) + const cancelTaskMock = mock(async () => true) + const bgMgr = { + getTasksByParentSession: getTasksByParentSessionMock, + cancelTask: cancelTaskMock, + } as unknown as BackgroundManager + + // when + await deleteTeam(fixture.teamRunId, fixture.config, undefined, bgMgr) + + // then + expect(cancelTaskMock).toHaveBeenCalledTimes(1) + const cancelledTaskId = cancelTaskMock.mock.calls[0]?.[0] + expect(cancelledTaskId).toBe("team-task-a") + }) +}) diff --git a/src/features/team-mode/team-runtime/delete-team.ts b/src/features/team-mode/team-runtime/delete-team.ts new file mode 100644 index 000000000..385e90a3e --- /dev/null +++ b/src/features/team-mode/team-runtime/delete-team.ts @@ -0,0 +1,134 @@ +import type { TeamModeConfig } from "../../../config/schema/team-mode" +import { log } from "../../../shared/logger" +import type { BackgroundManager } from "../../background-agent/manager" +import type { TmuxSessionManager } from "../../tmux-subagent/manager" +import { canVisualize, removeTeamLayout } from "../team-layout-tmux/layout" +import { sweepStaleTeamSessions } from "../team-layout-tmux/sweep-stale-team-sessions" +import { getRuntimeStateDir, resolveBaseDir } from "../team-registry/paths" +import { unregisterTeamSessionsByTeam } from "../team-session-registry" +import { listActiveTeams, loadRuntimeState, saveRuntimeState, transitionRuntimeState } from "../team-state-store/store" +import type { RuntimeState } from "../types" +import { DELETABLE_MEMBER_STATUSES, removeWorktrees } from "./shutdown-helpers" + +const DELETABLE_TEAM_STATUSES = new Set([ + "active", + "shutdown_requested", + "deleting", + "deleted", +]) + +const FORCE_DELETABLE_TEAM_STATUSES = new Set([ + ...DELETABLE_TEAM_STATUSES, + "creating", + "orphaned", +]) + +const FORCE_COMPLETABLE_MEMBER_STATUSES = new Set([ + "pending", + "running", + "idle", +]) + +const FORCE_BYPASS_DELETING_STATUSES = new Set(["creating", "orphaned"]) + +export async function deleteTeam( + teamRunId: string, + config: TeamModeConfig, + tmuxMgr?: TmuxSessionManager, + bgMgr?: BackgroundManager, + options?: { force?: boolean }, +): Promise<{ removedWorktrees: string[]; removedLayout: boolean }> { + const runtimeState = await loadRuntimeState(teamRunId, config) + const nonLeadMembers = runtimeState.members.filter((member) => member.agentType !== "leader") + + if (bgMgr && runtimeState.leadSessionId) { + const teamMessageMarkerPrefix = `team-create:${teamRunId}:` + const teamTasks = bgMgr.getTasksByParentSession(runtimeState.leadSessionId) + .filter((task) => task.teamRunId === teamRunId || task.parentMessageID?.startsWith(teamMessageMarkerPrefix)) + await Promise.all(teamTasks.map((task) => bgMgr.cancelTask(task.id, { + source: "team-mode-delete", + reason: `delete team ${teamRunId}`, + }))) + } + + if (options?.force === true) { + await transitionRuntimeState(teamRunId, (currentRuntimeState) => ({ + ...currentRuntimeState, + members: currentRuntimeState.members.map((member) => ( + member.agentType === "leader" || !FORCE_COMPLETABLE_MEMBER_STATUSES.has(member.status) + ? member + : { ...member, status: "completed" } + )), + }), config) + } else if (nonLeadMembers.some((member) => !DELETABLE_MEMBER_STATUSES.has(member.status))) { + throw new Error("members still active") + } + + const deletableTeamStatuses = options?.force === true + ? FORCE_DELETABLE_TEAM_STATUSES + : DELETABLE_TEAM_STATUSES + if (!deletableTeamStatuses.has(runtimeState.status)) { + throw new Error(`team cannot be deleted from '${runtimeState.status}'`) + } + + if (runtimeState.status !== "deleting" && runtimeState.status !== "deleted") { + if (options?.force === true && FORCE_BYPASS_DELETING_STATUSES.has(runtimeState.status)) { + const currentRuntimeState = await loadRuntimeState(teamRunId, config) + if (currentRuntimeState.status !== "deleting" && currentRuntimeState.status !== "deleted") { + await saveRuntimeState({ ...currentRuntimeState, status: "deleting" }, config) + } + } else { + await transitionRuntimeState(teamRunId, (currentRuntimeState) => ( + currentRuntimeState.status === "deleting" + ? currentRuntimeState + : { ...currentRuntimeState, status: "deleting" } + ), config) + } + } + + const removedLayout = tmuxMgr !== undefined && canVisualize() + if (removedLayout) { + const memberPaneIds = runtimeState.members + .filter((member) => member.agentType !== "leader" && member.tmuxPaneId) + .map((member) => member.tmuxPaneId!) + + const cleanupTarget = runtimeState.tmuxLayout + ? { + ...runtimeState.tmuxLayout, + paneIds: memberPaneIds.length > 0 ? memberPaneIds : undefined, + } + : undefined + + if (options?.force === true) { + try { + await removeTeamLayout(teamRunId, cleanupTarget, tmuxMgr) + } catch (error) { + log("team delete layout cleanup failed", { + teamRunId, + error: error instanceof Error ? error.message : String(error), + }) + } + } else { + await removeTeamLayout(teamRunId, cleanupTarget, tmuxMgr) + } + } + + const removedWorktrees = await removeWorktrees(runtimeState.members.map((member) => member.worktreePath)) + + if (runtimeState.status !== "deleted") { + await transitionRuntimeState(teamRunId, (currentRuntimeState) => ( + currentRuntimeState.status === "deleted" + ? currentRuntimeState + : { ...currentRuntimeState, status: "deleted" } + ), config) + } + + await removeWorktrees([getRuntimeStateDir(resolveBaseDir(config), teamRunId)]) + + unregisterTeamSessionsByTeam(teamRunId) + + const activeTeams = await listActiveTeams(config) + sweepStaleTeamSessions(new Set(activeTeams.map((team) => team.teamRunId))).catch(() => {}) + + return { removedWorktrees, removedLayout } +}