fix(team-mode): track session-created team runs

This commit is contained in:
YeonGyu-Kim
2026-05-11 12:37:20 +09:00
parent ac9e375d85
commit 59209f0659
8 changed files with 199 additions and 1 deletions
@@ -7,6 +7,7 @@ import { removeTeamLayout } from "../team-layout-tmux/layout"
import { unregisterTeamSessionsByTeam } from "../team-session-registry"
import { loadRuntimeState, transitionRuntimeState } from "../team-state-store/store"
import type { TeamRunCreateError } from "./create"
import { unregisterTeamRunForSessionCleanup } from "./session-team-run-registry"
type SpawnedMemberResource = {
taskId?: string
@@ -72,6 +73,7 @@ export async function cleanupTeamRunResources(args: {
})
unregisterTeamSessionsByTeam(args.teamRunId)
unregisterTeamRunForSessionCleanup(args.teamRunId)
return cleanupReport
}
@@ -1,6 +1,6 @@
/// <reference types="bun-types" />
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { access, mkdtemp, readdir, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
@@ -14,6 +14,10 @@ import { BackgroundManager } from "../../background-agent/manager"
import { loadRuntimeState } from "../team-state-store/store"
import { clearTeamSessionRegistry, lookupTeamSession } from "../team-session-registry"
import type { TeamSpec } from "../types"
import {
clearSessionTeamRunCleanupRegistry,
getSessionCreatedTeamRunIds,
} from "./session-cleanup"
const resolveMemberMock = mock(async (member: TeamSpec["members"][number]) => ({
agentToUse: `${member.name}-agent`,
@@ -92,9 +96,15 @@ describe("createTeamRun", () => {
beforeEach(() => {
resolveMemberMock.mockClear()
clearTeamSessionRegistry()
clearSessionTeamRunCleanupRegistry()
})
afterEach(() => {
clearSessionTeamRunCleanupRegistry()
})
afterAll(async () => {
clearSessionTeamRunCleanupRegistry()
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => rm(directoryPath, { recursive: true, force: true })))
})
@@ -117,6 +127,19 @@ describe("createTeamRun", () => {
expect((launchMock.mock.calls as Array<[LaunchInput]>).every(([input]) => input.suppressTmuxSpawn === true)).toBe(true)
})
test("#given a new team runtime #when createTeamRun succeeds #then it registers the run for session cleanup", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-session-cleanup-"))
temporaryDirectories.push(baseDir)
const { manager } = createManager(baseDir, async () => ({ id: "task-1", sessionId: "session-1", status: "running" } as BackgroundTask))
// when
const runtimeState = await createTeamRun(createSpec(1), "lead-session", createContext(baseDir, manager), createConfig(baseDir), manager)
// then
expect(getSessionCreatedTeamRunIds()).toEqual([runtimeState.teamRunId])
})
test("registers a member session as soon as launch reports the real sessionId", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-session-lineage-"))
@@ -230,6 +253,7 @@ describe("createTeamRun", () => {
}
expect((cancelTaskMock.mock.calls as Array<[string]>).map(([taskId]) => taskId)).toEqual(["task-3", "task-2", "task-1"])
expect((await loadSingleRuntimeState(baseDir)).status).toBe("failed")
expect(getSessionCreatedTeamRunIds()).toEqual([])
})
test("removes all created worktrees when spawn fails after worktree creation", async () => {
@@ -17,6 +17,7 @@ import { buildTeammateCommunicationAddendum } from "../member-guidance"
import { resolveMember } from "./resolve-member"
import { shouldReuseCallerLeadSession } from "../resolve-caller-team-lead"
import { sweepStaleTeamSessions } from "../team-layout-tmux/sweep-stale-team-sessions"
import { registerTeamRunForSessionCleanup } from "./session-team-run-registry"
const SESSION_ID_POLL_MS = 25
@@ -129,6 +130,7 @@ export async function createTeamRun(
await ensureBaseDirs(baseDir)
const reusesCallerLeadSession = shouldReuseCallerLeadSession(spec, options?.callerAgentTypeId)
let runtimeState = await createRuntimeState(spec, leadSessionId, await resolveSpecSource(spec, ctx, config), config)
registerTeamRunForSessionCleanup(runtimeState.teamRunId)
if (reusesCallerLeadSession && spec.leadAgentId) {
const callerLeadSubagentType = options?.callerAgentTypeId
registerTeamSession(leadSessionId, {
@@ -9,6 +9,7 @@ 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"
import { unregisterTeamRunForSessionCleanup } from "./session-team-run-registry"
export type DeleteTeamDeps = {
canVisualize: typeof canVisualize
@@ -139,6 +140,7 @@ export async function deleteTeam(
await removeWorktrees([getRuntimeStateDir(resolveBaseDir(config), teamRunId)])
unregisterTeamSessionsByTeam(teamRunId)
unregisterTeamRunForSessionCleanup(teamRunId)
const activeTeams = await listActiveTeams(config)
sweepStaleTeamSessions(new Set(activeTeams.map((team) => team.teamRunId))).catch(() => {})
@@ -0,0 +1,57 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, mock, test } from "bun:test"
import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
import type { BackgroundManager } from "../../background-agent/manager"
import type { TmuxSessionManager } from "../../tmux-subagent/manager"
import type { deleteTeam } from "./delete-team"
import {
cleanupSessionTeamRuns,
clearSessionTeamRunCleanupRegistry,
getSessionCreatedTeamRunIds,
registerTeamRunForSessionCleanup,
} from "./session-cleanup"
describe("session team cleanup", () => {
afterEach(() => {
clearSessionTeamRunCleanupRegistry()
mock.restore()
})
test("#given team runs created in this process #when session cleanup runs #then it force deletes them with the tmux visualizer manager", async () => {
// given
const config = TeamModeConfigSchema.parse({ enabled: true, tmux_visualization: true })
const tmuxMgr = { getServerUrl: () => "http://127.0.0.1:4096" } as TmuxSessionManager
const bgMgr = { cancelTask: mock(async () => true) } as BackgroundManager
const deleteTeamMock = mock(async () => ({
removedLayout: true,
removedWorktrees: [],
})) as typeof deleteTeam
registerTeamRunForSessionCleanup("team-run-a")
registerTeamRunForSessionCleanup("team-run-b")
// when
const report = await cleanupSessionTeamRuns({
config,
tmuxMgr,
bgMgr,
deps: {
deleteTeam: deleteTeamMock,
log: mock(() => {}),
},
})
// then
expect(deleteTeamMock).toHaveBeenCalledTimes(2)
expect(deleteTeamMock).toHaveBeenNthCalledWith(1, "team-run-a", config, tmuxMgr, bgMgr, { force: true })
expect(deleteTeamMock).toHaveBeenNthCalledWith(2, "team-run-b", config, tmuxMgr, bgMgr, { force: true })
expect(report).toEqual({
cleanedTeamRunIds: ["team-run-a", "team-run-b"],
removedLayoutTeamRunIds: ["team-run-a", "team-run-b"],
errors: [],
})
expect(getSessionCreatedTeamRunIds()).toEqual([])
})
})
@@ -0,0 +1,71 @@
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 { deleteTeam } from "./delete-team"
import {
getSessionCreatedTeamRunIds,
unregisterTeamRunForSessionCleanup,
} from "./session-team-run-registry"
export {
clearSessionTeamRunCleanupRegistry,
getSessionCreatedTeamRunIds,
registerTeamRunForSessionCleanup,
unregisterTeamRunForSessionCleanup,
} from "./session-team-run-registry"
export type SessionTeamCleanupReport = {
cleanedTeamRunIds: string[]
removedLayoutTeamRunIds: string[]
errors: string[]
}
export type SessionTeamCleanupDeps = {
deleteTeam: typeof deleteTeam
log: typeof log
}
const defaultSessionTeamCleanupDeps: SessionTeamCleanupDeps = {
deleteTeam,
log,
}
function normalizeError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
export async function cleanupSessionTeamRuns(args: {
config: TeamModeConfig
tmuxMgr?: TmuxSessionManager
bgMgr?: BackgroundManager
deps?: SessionTeamCleanupDeps
}): Promise<SessionTeamCleanupReport> {
const deps = args.deps ?? defaultSessionTeamCleanupDeps
const report: SessionTeamCleanupReport = {
cleanedTeamRunIds: [],
removedLayoutTeamRunIds: [],
errors: [],
}
for (const teamRunId of getSessionCreatedTeamRunIds()) {
try {
const result = await deps.deleteTeam(teamRunId, args.config, args.tmuxMgr, args.bgMgr, { force: true })
report.cleanedTeamRunIds.push(teamRunId)
if (result.removedLayout) {
report.removedLayoutTeamRunIds.push(teamRunId)
}
} catch (error) {
const normalizedError = normalizeError(error)
report.errors.push(`${teamRunId}: ${normalizedError.message}`)
deps.log("session team cleanup failed", {
teamRunId,
error: normalizedError.message,
})
} finally {
unregisterTeamRunForSessionCleanup(teamRunId)
}
}
return report
}
@@ -0,0 +1,17 @@
const sessionCreatedTeamRunIds = new Set<string>()
export function registerTeamRunForSessionCleanup(teamRunId: string): void {
sessionCreatedTeamRunIds.add(teamRunId)
}
export function unregisterTeamRunForSessionCleanup(teamRunId: string): void {
sessionCreatedTeamRunIds.delete(teamRunId)
}
export function getSessionCreatedTeamRunIds(): string[] {
return Array.from(sessionCreatedTeamRunIds)
}
export function clearSessionTeamRunCleanupRegistry(): void {
sessionCreatedTeamRunIds.clear()
}
@@ -15,6 +15,11 @@ import {
readInboxMessages,
updateMemberStatuses,
} from "./shutdown-test-fixtures"
import {
clearSessionTeamRunCleanupRegistry,
getSessionCreatedTeamRunIds,
registerTeamRunForSessionCleanup,
} from "./session-cleanup"
const { approveShutdown, deleteTeam, rejectShutdown, requestShutdownOfMember } = await import("./shutdown")
@@ -25,6 +30,7 @@ describe("team-runtime shutdown", () => {
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
await rm(directoryPath, { recursive: true, force: true })
}))
clearSessionTeamRunCleanupRegistry()
mock.restore()
})
@@ -161,6 +167,23 @@ describe("team-runtime shutdown", () => {
)
})
test("#given a team run is tracked for session cleanup #when deleteTeam succeeds #then it unregisters the run", async () => {
// given
const fixture = await createFixture()
temporaryDirectories.push(fixture.baseDir)
registerTeamRunForSessionCleanup(fixture.teamRunId)
await updateMemberStatuses(fixture.teamRunId, fixture.config, {
"member-a": "shutdown_approved",
"member-b": "shutdown_approved",
})
// when
await deleteTeam(fixture.teamRunId, fixture.config)
// then
expect(getSessionCreatedTeamRunIds()).toEqual([])
})
test("deletes team even with active members when force=true", async () => {
// given
const fixture = await createFixture()