diff --git a/src/hooks/team-session-events/team-lead-orphan-handler.test.ts b/src/hooks/team-session-events/team-lead-orphan-handler.test.ts new file mode 100644 index 000000000..431ad1e5e --- /dev/null +++ b/src/hooks/team-session-events/team-lead-orphan-handler.test.ts @@ -0,0 +1,169 @@ +/// + +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { randomUUID } from "node:crypto" +import { mkdtemp, mkdir, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" + +import { TeamModeConfigSchema } from "../../config/schema/team-mode" +import type { TeamModeConfig } from "../../config/schema/team-mode" +import * as deleteTeamModule from "../../features/team-mode/team-runtime/delete-team" +import { + clearTeamSessionRegistry, + registerTeamSession, +} from "../../features/team-mode/team-session-registry" +import type { RuntimeState } from "../../features/team-mode/types" +import { loadRuntimeState, saveRuntimeState } from "../../features/team-mode/team-state-store/store" +import { createTeamLeadOrphanHandler } from "./team-lead-orphan-handler" + +const temporaryDirectories: string[] = [] + +async function createTemporaryBaseDir(): Promise { + const baseDir = await mkdtemp(path.join(tmpdir(), "team-lead-orphan-handler-")) + temporaryDirectories.push(baseDir) + return baseDir +} + +function createConfig(baseDir: string): TeamModeConfig { + return TeamModeConfigSchema.parse({ base_dir: baseDir, enabled: true }) +} + +function createRuntimeState(teamRunId: string): RuntimeState { + return { + version: 1, + teamRunId, + teamName: "team-alpha", + specSource: "project", + createdAt: 1, + status: "active", + leadSessionId: "lead-session", + members: [ + { + name: "worker", + sessionId: "member-session", + agentType: "general-purpose", + status: "running", + pendingInjectedMessageIds: [], + }, + ], + shutdownRequests: [], + bounds: { + maxMembers: 8, + maxParallelMembers: 4, + maxMessagesPerRun: 10000, + maxWallClockMinutes: 120, + maxMemberTurns: 500, + }, + } +} + +async function seedRuntimeState(runtimeState: RuntimeState, config: TeamModeConfig): Promise { + await mkdir(path.join(config.base_dir ?? "", "runtime", runtimeState.teamRunId), { recursive: true }) + await saveRuntimeState(runtimeState, config) +} + +afterEach(async () => { + mock.restore() + clearTeamSessionRegistry() + await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => { + await rm(directoryPath, { recursive: true, force: true }) + })) +}) + +describe("createTeamLeadOrphanHandler", () => { + test("#given the deleted session matches the lead #when the orphan handler runs #then it marks the team orphaned and force-deletes the team", async () => { + // given + const baseDir = await createTemporaryBaseDir() + const config = createConfig(baseDir) + const teamRunId = randomUUID() + await seedRuntimeState(createRuntimeState(teamRunId), config) + const deleteTeamSpy = spyOn(deleteTeamModule, "deleteTeam") + deleteTeamSpy.mockResolvedValue({ removedLayout: true, removedWorktrees: [] }) + const handler = createTeamLeadOrphanHandler(config) + + // when + await handler({ + event: { + type: "session.deleted", + properties: { info: { id: "lead-session" } }, + }, + }) + + // then + const runtimeState = await loadRuntimeState(teamRunId, config) + expect(runtimeState.status).toBe("orphaned") + expect(deleteTeamSpy).toHaveBeenCalledTimes(1) + expect(deleteTeamSpy).toHaveBeenCalledWith(teamRunId, config, undefined, undefined, { force: true }) + }) + + test("#given the registry tracks a fresh lead session before disk state persists it #when the orphan handler runs #then it still marks the team orphaned and force-deletes it", async () => { + // given + const baseDir = await createTemporaryBaseDir() + const config = createConfig(baseDir) + const teamRunId = randomUUID() + await seedRuntimeState({ + ...createRuntimeState(teamRunId), + leadSessionId: undefined, + }, config) + registerTeamSession("lead-session", { + teamRunId, + memberName: "lead", + role: "lead", + }) + const deleteTeamSpy = spyOn(deleteTeamModule, "deleteTeam") + deleteTeamSpy.mockResolvedValue({ removedLayout: false, removedWorktrees: [] }) + const handler = createTeamLeadOrphanHandler(config) + + // when + await handler({ + event: { + type: "session.deleted", + properties: { info: { id: "lead-session" } }, + }, + }) + + // then + const runtimeState = await loadRuntimeState(teamRunId, config) + expect(runtimeState.status).toBe("orphaned") + expect(deleteTeamSpy).toHaveBeenCalledTimes(1) + expect(deleteTeamSpy).toHaveBeenCalledWith(teamRunId, config, undefined, undefined, { force: true }) + }) + + test("#given the registry points the lead session at the wrong teamRunId #when the orphan handler runs #then it falls back to disk lookup, orphans the correct team, and force-deletes it", async () => { + // given + const baseDir = await createTemporaryBaseDir() + const config = createConfig(baseDir) + const correctTeamRunId = randomUUID() + const wrongTeamRunId = randomUUID() + await seedRuntimeState(createRuntimeState(correctTeamRunId), config) + await seedRuntimeState({ + ...createRuntimeState(wrongTeamRunId), + leadSessionId: "other-lead-session", + }, config) + registerTeamSession("lead-session", { + teamRunId: wrongTeamRunId, + memberName: "lead", + role: "lead", + }) + const deleteTeamSpy = spyOn(deleteTeamModule, "deleteTeam") + deleteTeamSpy.mockResolvedValue({ removedLayout: false, removedWorktrees: [] }) + const handler = createTeamLeadOrphanHandler(config) + + // when + await handler({ + event: { + type: "session.deleted", + properties: { info: { id: "lead-session" } }, + }, + }) + + // then + const correctRuntimeState = await loadRuntimeState(correctTeamRunId, config) + const wrongRuntimeState = await loadRuntimeState(wrongTeamRunId, config) + expect(correctRuntimeState.status).toBe("orphaned") + expect(wrongRuntimeState.status).toBe("active") + expect(deleteTeamSpy).toHaveBeenCalledTimes(1) + expect(deleteTeamSpy).toHaveBeenCalledWith(correctTeamRunId, config, undefined, undefined, { force: true }) + }) +}) diff --git a/src/hooks/team-session-events/team-lead-orphan-handler.ts b/src/hooks/team-session-events/team-lead-orphan-handler.ts new file mode 100644 index 000000000..e7b70b3d5 --- /dev/null +++ b/src/hooks/team-session-events/team-lead-orphan-handler.ts @@ -0,0 +1,108 @@ +import type { TeamModeConfig } from "../../config/schema/team-mode" +import type { BackgroundManager } from "../../features/background-agent/manager" +import { lookupTeamSession } from "../../features/team-mode/team-session-registry" +import { loadRuntimeState, listActiveTeams, transitionRuntimeState } from "../../features/team-mode/team-state-store/store" +import type { TmuxSessionManager } from "../../features/tmux-subagent/manager" +import { log } from "../../shared/logger" + +type HookInput = { event: { type: string; properties?: unknown } } +export type HookImpl = (input: HookInput) => Promise + +function getDeletedSessionID(properties: unknown): string | undefined { + const record = properties as { info?: { id?: string } } | undefined + return record?.info?.id +} + +async function findLeadTeamRunId( + deletedSessionID: string, + config: TeamModeConfig, +): Promise { + const registryEntry = lookupTeamSession(deletedSessionID) + if (registryEntry?.role === "lead") { + try { + const runtimeState = await loadRuntimeState(registryEntry.teamRunId, config) + if (runtimeState.leadSessionId === undefined || runtimeState.leadSessionId === deletedSessionID) { + return runtimeState.teamRunId + } + } catch (error) { + log("team lead orphan handler registry lookup failed", { + event: "team-mode-lead-orphan-handler-registry-error", + teamRunId: registryEntry.teamRunId, + deletedSessionID, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + const activeTeams = await listActiveTeams(config) + + for (const activeTeam of activeTeams) { + try { + const runtimeState = await loadRuntimeState(activeTeam.teamRunId, config) + if (runtimeState.leadSessionId === deletedSessionID) { + return runtimeState.teamRunId + } + } catch (error) { + log("team lead orphan handler skipped runtime", { + event: "team-mode-lead-orphan-handler-runtime-error", + teamRunId: activeTeam.teamRunId, + deletedSessionID, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + return null +} + +export function createTeamLeadOrphanHandler( + config: TeamModeConfig, + tmuxMgr?: TmuxSessionManager, + bgMgr?: BackgroundManager, +): HookImpl { + return async ({ event }: HookInput): Promise => { + if (event.type !== "session.deleted") return + + const deletedSessionID = getDeletedSessionID(event.properties) + if (!deletedSessionID) return + + try { + const teamRunId = await findLeadTeamRunId(deletedSessionID, config) + if (teamRunId === null) { + return + } + + const runtimeState = await loadRuntimeState(teamRunId, config) + const nextRuntimeState = await transitionRuntimeState(runtimeState.teamRunId, (currentRuntimeState) => ({ + ...currentRuntimeState, + status: "orphaned", + }), config) + + log("team lead session deleted", { + event: "team-mode-lead-orphaned", + teamRunId: runtimeState.teamRunId, + teamName: runtimeState.teamName, + deletedSessionID, + previousStatus: runtimeState.status, + nextStatus: nextRuntimeState.status, + }) + + try { + const { deleteTeam } = await import("../../features/team-mode/team-runtime/delete-team") + await deleteTeam(teamRunId, config, tmuxMgr, bgMgr, { force: true }) + } catch (deleteError) { + log("team lead orphan cleanup failed (non-fatal)", { + event: "team-mode-lead-orphan-cleanup-error", + teamRunId, + error: deleteError instanceof Error ? deleteError.message : String(deleteError), + }) + } + } catch (error) { + log("team lead orphan handler failed", { + event: "team-mode-lead-orphan-handler-error", + deletedSessionID, + error: error instanceof Error ? error.message : String(error), + }) + } + } +}