feat(team-mode): add team runtime resource cleanup with tests
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
import type { TeamModeConfig } from "../../../config/schema/team-mode"
|
||||
import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
|
||||
import type { BackgroundManager } from "../../background-agent/manager"
|
||||
import {
|
||||
clearTeamSessionRegistry,
|
||||
lookupTeamSession,
|
||||
registerTeamSession,
|
||||
} from "../team-session-registry"
|
||||
import { saveRuntimeState } from "../team-state-store/store"
|
||||
import type { RuntimeState } from "../types"
|
||||
import { cleanupTeamRunResources } from "./cleanup-team-run-resources"
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
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: "creating",
|
||||
leadSessionId: "lead-session",
|
||||
members: [
|
||||
{ name: "worker-1", agentType: "general-purpose", status: "pending", pendingInjectedMessageIds: [] },
|
||||
],
|
||||
shutdownRequests: [],
|
||||
bounds: { maxMembers: 8, maxParallelMembers: 4, maxMessagesPerRun: 10_000, maxWallClockMinutes: 120, maxMemberTurns: 500 },
|
||||
}
|
||||
}
|
||||
|
||||
function createStubBgMgr(): BackgroundManager {
|
||||
return {
|
||||
cancelTask: async () => undefined,
|
||||
} as unknown as BackgroundManager
|
||||
}
|
||||
|
||||
describe("cleanupTeamRunResources", () => {
|
||||
afterEach(async () => {
|
||||
clearTeamSessionRegistry()
|
||||
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => rm(directoryPath, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
test("unregisters every team-session-registry entry for the failed team so the gating hook cannot authorize stale participants", async () => {
|
||||
// given
|
||||
const baseDir = await mkdtemp(path.join(tmpdir(), "cleanup-team-run-registry-"))
|
||||
temporaryDirectories.push(baseDir)
|
||||
const teamRunId = "33333333-3333-4333-8333-333333333333"
|
||||
await mkdir(path.join(baseDir, "runtime", teamRunId), { recursive: true })
|
||||
await saveRuntimeState(createRuntimeState(teamRunId), createConfig(baseDir))
|
||||
registerTeamSession("lead-session", { teamRunId, memberName: "lead", role: "lead" })
|
||||
registerTeamSession("worker-session", { teamRunId, memberName: "worker-1", role: "member" })
|
||||
registerTeamSession("other-team-session", { teamRunId: "other-team", memberName: "solo", role: "member" })
|
||||
|
||||
// when
|
||||
await cleanupTeamRunResources({
|
||||
teamRunId,
|
||||
config: createConfig(baseDir),
|
||||
resources: [{}],
|
||||
bgMgr: createStubBgMgr(),
|
||||
createdLayout: false,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(lookupTeamSession("lead-session")).toBeUndefined()
|
||||
expect(lookupTeamSession("worker-session")).toBeUndefined()
|
||||
expect(lookupTeamSession("other-team-session")).toEqual({ teamRunId: "other-team", memberName: "solo", role: "member" })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { rm } from "node:fs/promises"
|
||||
|
||||
import type { TeamModeConfig } from "../../../config/schema/team-mode"
|
||||
import type { BackgroundManager } from "../../background-agent/manager"
|
||||
import type { TmuxSessionManager } from "../../tmux-subagent/manager"
|
||||
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"
|
||||
|
||||
type SpawnedMemberResource = {
|
||||
taskId?: string
|
||||
worktreePath?: string
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
export async function cleanupTeamRunResources(args: {
|
||||
teamRunId: string
|
||||
config: TeamModeConfig
|
||||
resources: SpawnedMemberResource[]
|
||||
bgMgr: BackgroundManager
|
||||
tmuxMgr?: TmuxSessionManager
|
||||
createdLayout: boolean
|
||||
}): Promise<TeamRunCreateError["cleanupReport"]> {
|
||||
const cleanupReport: TeamRunCreateError["cleanupReport"] = {
|
||||
cancelledTaskIds: [],
|
||||
removedLayout: false,
|
||||
removedWorktrees: [],
|
||||
errors: [],
|
||||
}
|
||||
|
||||
for (const resource of [...args.resources].reverse()) {
|
||||
if (resource.taskId) {
|
||||
try {
|
||||
await args.bgMgr.cancelTask(resource.taskId, {
|
||||
source: "team-create-rollback",
|
||||
reason: "creating_rollback",
|
||||
skipNotification: true,
|
||||
})
|
||||
cleanupReport.cancelledTaskIds.push(resource.taskId)
|
||||
} catch (cancelError) {
|
||||
cleanupReport.errors.push(`cancel ${resource.taskId}: ${normalizeError(cancelError).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (resource.worktreePath) {
|
||||
try {
|
||||
await rm(resource.worktreePath, { recursive: true, force: true })
|
||||
cleanupReport.removedWorktrees.push(resource.worktreePath)
|
||||
} catch (cleanupError) {
|
||||
cleanupReport.errors.push(`worktree ${resource.worktreePath}: ${normalizeError(cleanupError).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.createdLayout && args.tmuxMgr) {
|
||||
try {
|
||||
const runtimeState = await loadRuntimeState(args.teamRunId, args.config)
|
||||
await removeTeamLayout(args.teamRunId, runtimeState.tmuxLayout, args.tmuxMgr)
|
||||
cleanupReport.removedLayout = true
|
||||
} catch (layoutError) {
|
||||
cleanupReport.errors.push(`layout ${args.teamRunId}: ${normalizeError(layoutError).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
await transitionRuntimeState(args.teamRunId, (runtimeState) => ({ ...runtimeState, status: "failed" }), args.config).catch((transitionError) => {
|
||||
cleanupReport.errors.push(`state ${args.teamRunId}: ${normalizeError(transitionError).message}`)
|
||||
return undefined
|
||||
})
|
||||
|
||||
unregisterTeamSessionsByTeam(args.teamRunId)
|
||||
|
||||
return cleanupReport
|
||||
}
|
||||
Reference in New Issue
Block a user