fix(team-mode): track session-created team runs
This commit is contained in:
@@ -7,6 +7,7 @@ import { removeTeamLayout } from "../team-layout-tmux/layout"
|
|||||||
import { unregisterTeamSessionsByTeam } from "../team-session-registry"
|
import { unregisterTeamSessionsByTeam } from "../team-session-registry"
|
||||||
import { loadRuntimeState, transitionRuntimeState } from "../team-state-store/store"
|
import { loadRuntimeState, transitionRuntimeState } from "../team-state-store/store"
|
||||||
import type { TeamRunCreateError } from "./create"
|
import type { TeamRunCreateError } from "./create"
|
||||||
|
import { unregisterTeamRunForSessionCleanup } from "./session-team-run-registry"
|
||||||
|
|
||||||
type SpawnedMemberResource = {
|
type SpawnedMemberResource = {
|
||||||
taskId?: string
|
taskId?: string
|
||||||
@@ -72,6 +73,7 @@ export async function cleanupTeamRunResources(args: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
unregisterTeamSessionsByTeam(args.teamRunId)
|
unregisterTeamSessionsByTeam(args.teamRunId)
|
||||||
|
unregisterTeamRunForSessionCleanup(args.teamRunId)
|
||||||
|
|
||||||
return cleanupReport
|
return cleanupReport
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="bun-types" />
|
/// <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 { access, mkdtemp, readdir, rm } from "node:fs/promises"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
@@ -14,6 +14,10 @@ import { BackgroundManager } from "../../background-agent/manager"
|
|||||||
import { loadRuntimeState } from "../team-state-store/store"
|
import { loadRuntimeState } from "../team-state-store/store"
|
||||||
import { clearTeamSessionRegistry, lookupTeamSession } from "../team-session-registry"
|
import { clearTeamSessionRegistry, lookupTeamSession } from "../team-session-registry"
|
||||||
import type { TeamSpec } from "../types"
|
import type { TeamSpec } from "../types"
|
||||||
|
import {
|
||||||
|
clearSessionTeamRunCleanupRegistry,
|
||||||
|
getSessionCreatedTeamRunIds,
|
||||||
|
} from "./session-cleanup"
|
||||||
|
|
||||||
const resolveMemberMock = mock(async (member: TeamSpec["members"][number]) => ({
|
const resolveMemberMock = mock(async (member: TeamSpec["members"][number]) => ({
|
||||||
agentToUse: `${member.name}-agent`,
|
agentToUse: `${member.name}-agent`,
|
||||||
@@ -92,9 +96,15 @@ describe("createTeamRun", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resolveMemberMock.mockClear()
|
resolveMemberMock.mockClear()
|
||||||
clearTeamSessionRegistry()
|
clearTeamSessionRegistry()
|
||||||
|
clearSessionTeamRunCleanupRegistry()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearSessionTeamRunCleanupRegistry()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
clearSessionTeamRunCleanupRegistry()
|
||||||
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => rm(directoryPath, { recursive: true, force: true })))
|
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)
|
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 () => {
|
test("registers a member session as soon as launch reports the real sessionId", async () => {
|
||||||
// given
|
// given
|
||||||
const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-session-lineage-"))
|
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((cancelTaskMock.mock.calls as Array<[string]>).map(([taskId]) => taskId)).toEqual(["task-3", "task-2", "task-1"])
|
||||||
expect((await loadSingleRuntimeState(baseDir)).status).toBe("failed")
|
expect((await loadSingleRuntimeState(baseDir)).status).toBe("failed")
|
||||||
|
expect(getSessionCreatedTeamRunIds()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("removes all created worktrees when spawn fails after worktree creation", async () => {
|
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 { resolveMember } from "./resolve-member"
|
||||||
import { shouldReuseCallerLeadSession } from "../resolve-caller-team-lead"
|
import { shouldReuseCallerLeadSession } from "../resolve-caller-team-lead"
|
||||||
import { sweepStaleTeamSessions } from "../team-layout-tmux/sweep-stale-team-sessions"
|
import { sweepStaleTeamSessions } from "../team-layout-tmux/sweep-stale-team-sessions"
|
||||||
|
import { registerTeamRunForSessionCleanup } from "./session-team-run-registry"
|
||||||
|
|
||||||
const SESSION_ID_POLL_MS = 25
|
const SESSION_ID_POLL_MS = 25
|
||||||
|
|
||||||
@@ -129,6 +130,7 @@ export async function createTeamRun(
|
|||||||
await ensureBaseDirs(baseDir)
|
await ensureBaseDirs(baseDir)
|
||||||
const reusesCallerLeadSession = shouldReuseCallerLeadSession(spec, options?.callerAgentTypeId)
|
const reusesCallerLeadSession = shouldReuseCallerLeadSession(spec, options?.callerAgentTypeId)
|
||||||
let runtimeState = await createRuntimeState(spec, leadSessionId, await resolveSpecSource(spec, ctx, config), config)
|
let runtimeState = await createRuntimeState(spec, leadSessionId, await resolveSpecSource(spec, ctx, config), config)
|
||||||
|
registerTeamRunForSessionCleanup(runtimeState.teamRunId)
|
||||||
if (reusesCallerLeadSession && spec.leadAgentId) {
|
if (reusesCallerLeadSession && spec.leadAgentId) {
|
||||||
const callerLeadSubagentType = options?.callerAgentTypeId
|
const callerLeadSubagentType = options?.callerAgentTypeId
|
||||||
registerTeamSession(leadSessionId, {
|
registerTeamSession(leadSessionId, {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { unregisterTeamSessionsByTeam } from "../team-session-registry"
|
|||||||
import { listActiveTeams, loadRuntimeState, saveRuntimeState, transitionRuntimeState } from "../team-state-store/store"
|
import { listActiveTeams, loadRuntimeState, saveRuntimeState, transitionRuntimeState } from "../team-state-store/store"
|
||||||
import type { RuntimeState } from "../types"
|
import type { RuntimeState } from "../types"
|
||||||
import { DELETABLE_MEMBER_STATUSES, removeWorktrees } from "./shutdown-helpers"
|
import { DELETABLE_MEMBER_STATUSES, removeWorktrees } from "./shutdown-helpers"
|
||||||
|
import { unregisterTeamRunForSessionCleanup } from "./session-team-run-registry"
|
||||||
|
|
||||||
export type DeleteTeamDeps = {
|
export type DeleteTeamDeps = {
|
||||||
canVisualize: typeof canVisualize
|
canVisualize: typeof canVisualize
|
||||||
@@ -139,6 +140,7 @@ export async function deleteTeam(
|
|||||||
await removeWorktrees([getRuntimeStateDir(resolveBaseDir(config), teamRunId)])
|
await removeWorktrees([getRuntimeStateDir(resolveBaseDir(config), teamRunId)])
|
||||||
|
|
||||||
unregisterTeamSessionsByTeam(teamRunId)
|
unregisterTeamSessionsByTeam(teamRunId)
|
||||||
|
unregisterTeamRunForSessionCleanup(teamRunId)
|
||||||
|
|
||||||
const activeTeams = await listActiveTeams(config)
|
const activeTeams = await listActiveTeams(config)
|
||||||
sweepStaleTeamSessions(new Set(activeTeams.map((team) => team.teamRunId))).catch(() => {})
|
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,
|
readInboxMessages,
|
||||||
updateMemberStatuses,
|
updateMemberStatuses,
|
||||||
} from "./shutdown-test-fixtures"
|
} from "./shutdown-test-fixtures"
|
||||||
|
import {
|
||||||
|
clearSessionTeamRunCleanupRegistry,
|
||||||
|
getSessionCreatedTeamRunIds,
|
||||||
|
registerTeamRunForSessionCleanup,
|
||||||
|
} from "./session-cleanup"
|
||||||
|
|
||||||
const { approveShutdown, deleteTeam, rejectShutdown, requestShutdownOfMember } = await import("./shutdown")
|
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 Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
|
||||||
await rm(directoryPath, { recursive: true, force: true })
|
await rm(directoryPath, { recursive: true, force: true })
|
||||||
}))
|
}))
|
||||||
|
clearSessionTeamRunCleanupRegistry()
|
||||||
mock.restore()
|
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 () => {
|
test("deletes team even with active members when force=true", async () => {
|
||||||
// given
|
// given
|
||||||
const fixture = await createFixture()
|
const fixture = await createFixture()
|
||||||
|
|||||||
Reference in New Issue
Block a user