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