diff --git a/src/features/team-mode/team-runtime/shutdown-helpers.ts b/src/features/team-mode/team-runtime/shutdown-helpers.ts new file mode 100644 index 000000000..49bbecdc1 --- /dev/null +++ b/src/features/team-mode/team-runtime/shutdown-helpers.ts @@ -0,0 +1,78 @@ +import { randomUUID } from "node:crypto" +import { rm } from "node:fs/promises" + +import type { Message, RuntimeState } from "../types" + +export const DELETABLE_MEMBER_STATUSES = new Set([ + "completed", + "shutdown_approved", + "errored", +]) + +export function createShutdownMessage(from: string, to: string, kind: Message["kind"], body: string): Message { + return { + version: 1, + messageId: randomUUID(), + from, + to, + kind, + body, + timestamp: Date.now(), + } +} + +export function getRuntimeMember(runtimeState: RuntimeState, memberName: string): RuntimeState["members"][number] { + const member = runtimeState.members.find((candidate) => candidate.name === memberName) + if (!member) { + throw new Error(`unknown member '${memberName}'`) + } + + return member +} + +export function getLeadMemberName(runtimeState: RuntimeState): string { + const leadMember = runtimeState.members.find((member) => member.agentType === "leader") + if (!leadMember) { + throw new Error(`team '${runtimeState.teamRunId}' is missing a lead member`) + } + + return leadMember.name +} + +export function createSendContext( + runtimeState: RuntimeState, + senderName: string, +): { isLead: boolean; activeMembers: string[] } { + const sender = getRuntimeMember(runtimeState, senderName) + return { + isLead: sender.agentType === "leader", + activeMembers: runtimeState.members.map((member) => member.name), + } +} + +export function findLatestShutdownRequestIndex( + runtimeState: RuntimeState, + memberName: string, + requesterName?: string, +): number { + for (let index = runtimeState.shutdownRequests.length - 1; index >= 0; index -= 1) { + const shutdownRequest = runtimeState.shutdownRequests[index] + if (shutdownRequest.memberId !== memberName) continue + if (requesterName !== undefined && shutdownRequest.requesterName !== requesterName) continue + return index + } + + return -1 +} + +export async function removeWorktrees(memberPaths: Array): Promise { + const removedWorktrees: string[] = [] + + for (const memberPath of new Set(memberPaths)) { + if (!memberPath) continue + await rm(memberPath, { recursive: true, force: true }) + removedWorktrees.push(memberPath) + } + + return removedWorktrees +} diff --git a/src/features/team-mode/team-runtime/shutdown-test-fixtures.ts b/src/features/team-mode/team-runtime/shutdown-test-fixtures.ts new file mode 100644 index 000000000..27b135aff --- /dev/null +++ b/src/features/team-mode/team-runtime/shutdown-test-fixtures.ts @@ -0,0 +1,146 @@ +import { mkdir, mkdtemp, readdir, readFile } 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 { sendMessage } from "../team-mailbox/send" +import { getInboxDir, getRuntimeStateDir, resolveBaseDir } from "../team-registry/paths" +import { saveRuntimeState, transitionRuntimeState } from "../team-state-store/store" +import { MessageSchema, type RuntimeState, type TeamSpec } from "../types" + +let fixtureCounter = 0 + +function createUuid(sequence: number): string { + return `123e4567-e89b-42d3-a456-${sequence.toString(16).padStart(12, "0")}` +} + +export function createConfig(baseDir: string): TeamModeConfig { + return TeamModeConfigSchema.parse({ base_dir: baseDir }) +} + +export function createSpec(worktreeRoot: string): TeamSpec { + fixtureCounter += 1 + + return { + version: 1, + name: `team-${fixtureCounter.toString(16).padStart(8, "0")}`, + createdAt: Date.now(), + leadAgentId: "lead", + members: [ + { kind: "subagent_type", name: "lead", subagent_type: "sisyphus", backendType: "in-process", isActive: true }, + { + kind: "category", + name: "member-a", + category: "deep", + prompt: "work on task a", + backendType: "in-process", + isActive: true, + worktreePath: path.join(worktreeRoot, "member-a"), + }, + { + kind: "category", + name: "member-b", + category: "deep", + prompt: "work on task b", + backendType: "in-process", + isActive: true, + worktreePath: path.join(worktreeRoot, "member-b"), + }, + ], + } +} + +export async function createFixture(options?: { status?: RuntimeState["status"] }): Promise<{ + baseDir: string + config: TeamModeConfig + teamRunId: string + worktreePaths: string[] +}> { + fixtureCounter += 1 + const baseDir = await mkdtemp(path.join(tmpdir(), `team-runtime-shutdown-${fixtureCounter}-`)) + const config = createConfig(baseDir) + const worktreeRoot = path.join(baseDir, "fixture-worktrees") + const teamRunId = createUuid(fixtureCounter) + const runtimeState: RuntimeState = { + version: 1, + teamRunId, + teamName: createSpec(worktreeRoot).name, + specSource: "project", + createdAt: Date.now(), + status: options?.status ?? "active", + leadSessionId: "lead-session", + members: [ + { name: "lead", agentType: "leader", status: "pending", pendingInjectedMessageIds: [] }, + { + name: "member-a", + agentType: "general-purpose", + status: "pending", + pendingInjectedMessageIds: [], + worktreePath: path.join(worktreeRoot, "member-a"), + }, + { + name: "member-b", + agentType: "general-purpose", + status: "pending", + pendingInjectedMessageIds: [], + worktreePath: path.join(worktreeRoot, "member-b"), + }, + ], + shutdownRequests: [], + bounds: { + maxMembers: config.max_members, + maxParallelMembers: config.max_parallel_members, + maxMessagesPerRun: config.max_messages_per_run, + maxWallClockMinutes: config.max_wall_clock_minutes, + maxMemberTurns: config.max_member_turns, + }, + } + await mkdir(getRuntimeStateDir(resolveBaseDir(config), teamRunId), { recursive: true }) + await saveRuntimeState(runtimeState, config) + + return { + baseDir, + config, + teamRunId: runtimeState.teamRunId, + worktreePaths: [path.join(worktreeRoot, "member-a"), path.join(worktreeRoot, "member-b")], + } +} + +export async function updateMemberStatuses( + teamRunId: string, + config: TeamModeConfig, + statuses: Record, +): Promise { + await transitionRuntimeState(teamRunId, (runtimeState) => ({ + ...runtimeState, + members: runtimeState.members.map((member) => ({ + ...member, + status: statuses[member.name] ?? member.status, + })), + }), config) +} + +export async function readInboxMessages(teamRunId: string, memberName: string, config: TeamModeConfig) { + const inboxDir = getInboxDir(resolveBaseDir(config), teamRunId, memberName) + const fileNames = (await readdir(inboxDir)).filter((entry) => entry.endsWith(".json")).sort() + return Promise.all(fileNames.map(async (fileName) => { + const content = await readFile(path.join(inboxDir, fileName), "utf8") + return MessageSchema.parse(JSON.parse(content)) + })) +} + +export function createTestMessage(overrides?: Partial[0]>) { + fixtureCounter += 1 + + return MessageSchema.parse({ + version: 1, + messageId: createUuid(fixtureCounter), + from: "lead", + to: "member-a", + kind: "message", + body: "hello", + timestamp: Date.now(), + ...overrides, + }) +} diff --git a/src/features/team-mode/team-runtime/shutdown.test.ts b/src/features/team-mode/team-runtime/shutdown.test.ts new file mode 100644 index 000000000..14b93f6fe --- /dev/null +++ b/src/features/team-mode/team-runtime/shutdown.test.ts @@ -0,0 +1,396 @@ +/// + +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { access, mkdir, rm } from "node:fs/promises" +import path from "node:path" + +import { sendMessage } from "../team-mailbox/send" +import { getRuntimeStateDir, resolveBaseDir } from "../team-registry/paths" +import * as logger from "../../../shared/logger" +import * as layoutModule from "../team-layout-tmux/layout" +import * as runtimeStateStore from "../team-state-store/store" +import { loadRuntimeState, transitionRuntimeState } from "../team-state-store/store" +import { + createFixture, + createTestMessage, + readInboxMessages, + updateMemberStatuses, +} from "./shutdown-test-fixtures" + +const { approveShutdown, deleteTeam, rejectShutdown, requestShutdownOfMember } = await import("./shutdown") + +describe("team-runtime shutdown", () => { + const temporaryDirectories: string[] = [] + + afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => { + await rm(directoryPath, { recursive: true, force: true }) + })) + mock.restore() + }) + + test("refuses team deletion while non-lead members are still active", async () => { + // given + const fixture = await createFixture() + temporaryDirectories.push(fixture.baseDir) + await updateMemberStatuses(fixture.teamRunId, fixture.config, { + "member-a": "running", + "member-b": "running", + }) + + // when + const result = deleteTeam(fixture.teamRunId, fixture.config) + + // then + await result.then( + () => { throw new Error("expected deleteTeam to reject") }, + (error: unknown) => { + if (!(error instanceof Error)) throw error + expect(error.message).toBe("members still active") + }, + ) + const runtimeState = await loadRuntimeState(fixture.teamRunId, fixture.config) + expect(runtimeState.status).toBe("active") + expect(runtimeState.members.filter((member) => member.agentType !== "leader").map((member) => member.status)).toEqual([ + "running", + "running", + ]) + }) + + test("writes shutdown requests to the target inbox and records runtime metadata", async () => { + // given + const fixture = await createFixture() + temporaryDirectories.push(fixture.baseDir) + + // when + await requestShutdownOfMember(fixture.teamRunId, "member-a", "lead", fixture.config) + + // then + const inboxMessages = await readInboxMessages(fixture.teamRunId, "member-a", fixture.config) + const runtimeState = await loadRuntimeState(fixture.teamRunId, fixture.config) + expect(inboxMessages).toHaveLength(1) + expect(inboxMessages[0]).toEqual(expect.objectContaining({ + from: "lead", + to: "member-a", + kind: "shutdown_request", + body: "", + })) + expect(runtimeState.shutdownRequests).toEqual([ + expect.objectContaining({ + memberId: "member-a", + requesterName: "lead", + requestedAt: expect.any(Number), + }), + ]) + }) + + test("approves shutdown requests and notifies the lead", async () => { + // given + const fixture = await createFixture() + temporaryDirectories.push(fixture.baseDir) + await requestShutdownOfMember(fixture.teamRunId, "member-a", "lead", fixture.config) + + // when + await approveShutdown(fixture.teamRunId, "member-a", "member-a", fixture.config) + + // then + const runtimeState = await loadRuntimeState(fixture.teamRunId, fixture.config) + const leadInboxMessages = await readInboxMessages(fixture.teamRunId, "lead", fixture.config) + const approvedRequest = runtimeState.shutdownRequests.find((shutdownRequest) => shutdownRequest.memberId === "member-a") + expect(approvedRequest?.approvedAt).toEqual(expect.any(Number)) + expect(runtimeState.members.find((member) => member.name === "member-a")?.status).toBe("shutdown_approved") + expect(leadInboxMessages.some((message) => ( + message.kind === "shutdown_approved" + && message.from === "member-a" + && message.to === "lead" + && message.body === "member-a" + ))).toBe(true) + }) + + test("rejects shutdown requests and replies to the original requester", async () => { + // given + const fixture = await createFixture() + temporaryDirectories.push(fixture.baseDir) + await requestShutdownOfMember(fixture.teamRunId, "member-a", "lead", fixture.config) + + // when + await rejectShutdown(fixture.teamRunId, "member-a", "not done yet", fixture.config) + + // then + const runtimeState = await loadRuntimeState(fixture.teamRunId, fixture.config) + const leadInboxMessages = await readInboxMessages(fixture.teamRunId, "lead", fixture.config) + const rejectedRequest = runtimeState.shutdownRequests.find((shutdownRequest) => shutdownRequest.memberId === "member-a") + expect(rejectedRequest).toEqual(expect.objectContaining({ + rejectedAt: expect.any(Number), + rejectedReason: "not done yet", + })) + expect(leadInboxMessages.some((message) => ( + message.kind === "shutdown_rejected" + && message.from === "member-a" + && message.to === "lead" + && message.body === "not done yet" + ))).toBe(true) + }) + + test("deletes team runtime resources after all non-lead members are approved", async () => { + // given + const fixture = await createFixture() + temporaryDirectories.push(fixture.baseDir) + await updateMemberStatuses(fixture.teamRunId, fixture.config, { + "member-a": "shutdown_approved", + "member-b": "shutdown_approved", + }) + await Promise.all(fixture.worktreePaths.map(async (worktreePath) => { + await mkdir(worktreePath, { recursive: true }) + })) + // when + const result = await deleteTeam(fixture.teamRunId, fixture.config) + + // then + expect(result.removedLayout).toBe(false) + expect(result.removedWorktrees.sort()).toEqual([...fixture.worktreePaths].sort()) + await Promise.all(fixture.worktreePaths.map(async (worktreePath) => { + await access(worktreePath).then( + () => { throw new Error(`expected ${worktreePath} to be removed`) }, + () => undefined, + ) + })) + const runtimeStateDirectory = getRuntimeStateDir(resolveBaseDir(fixture.config), fixture.teamRunId) + await access(runtimeStateDirectory).then( + () => { throw new Error(`expected ${runtimeStateDirectory} to be removed`) }, + () => undefined, + ) + }) + + test("deletes team even with active members when force=true", async () => { + // given + const fixture = await createFixture() + temporaryDirectories.push(fixture.baseDir) + await updateMemberStatuses(fixture.teamRunId, fixture.config, { + "member-a": "running", + "member-b": "running", + }) + await Promise.all(fixture.worktreePaths.map(async (worktreePath) => { + await mkdir(worktreePath, { recursive: true }) + })) + + // when + const result = await deleteTeam(fixture.teamRunId, fixture.config, undefined, undefined, { force: true }) + + // then + expect(result.removedLayout).toBe(false) + expect(result.removedWorktrees.sort()).toEqual([...fixture.worktreePaths].sort()) + await Promise.all(fixture.worktreePaths.map(async (worktreePath) => { + await access(worktreePath).then( + () => { throw new Error(`expected ${worktreePath} to be removed`) }, + () => undefined, + ) + })) + const runtimeStateDirectory = getRuntimeStateDir(resolveBaseDir(fixture.config), fixture.teamRunId) + await access(runtimeStateDirectory).then( + () => { throw new Error(`expected ${runtimeStateDirectory} to be removed`) }, + () => undefined, + ) + }) + + test("force deletes a team stuck in 'creating' status", async () => { + // given + const fixture = await createFixture({ status: "creating" }) + temporaryDirectories.push(fixture.baseDir) + const transitionedStatuses: string[] = [] + const originalTransitionRuntimeState = runtimeStateStore.transitionRuntimeState + spyOn(runtimeStateStore, "transitionRuntimeState").mockImplementation(async (teamRunId, transition, config) => { + const currentRuntimeState = await runtimeStateStore.loadRuntimeState(teamRunId, config) + transitionedStatuses.push(transition(currentRuntimeState).status) + return await originalTransitionRuntimeState(teamRunId, transition, config) + }) + await updateMemberStatuses(fixture.teamRunId, fixture.config, { + "member-a": "pending", + "member-b": "pending", + }) + + // when + await deleteTeam(fixture.teamRunId, fixture.config, undefined, undefined, { force: true }) + + // then + expect(transitionedStatuses).toContain("deleted") + const runtimeStateDirectory = getRuntimeStateDir(resolveBaseDir(fixture.config), fixture.teamRunId) + await access(runtimeStateDirectory).then( + () => { throw new Error(`expected ${runtimeStateDirectory} to be removed`) }, + () => undefined, + ) + }) + + test("force deletes a team in 'orphaned' status", async () => { + // given + const fixture = await createFixture({ status: "orphaned" }) + temporaryDirectories.push(fixture.baseDir) + const transitionedStatuses: string[] = [] + const originalTransitionRuntimeState = runtimeStateStore.transitionRuntimeState + spyOn(runtimeStateStore, "transitionRuntimeState").mockImplementation(async (teamRunId, transition, config) => { + const currentRuntimeState = await runtimeStateStore.loadRuntimeState(teamRunId, config) + transitionedStatuses.push(transition(currentRuntimeState).status) + return await originalTransitionRuntimeState(teamRunId, transition, config) + }) + await updateMemberStatuses(fixture.teamRunId, fixture.config, { + "member-a": "running", + "member-b": "running", + }) + + // when + await deleteTeam(fixture.teamRunId, fixture.config, undefined, undefined, { force: true }) + + // then + expect(transitionedStatuses).toContain("deleted") + const runtimeStateDirectory = getRuntimeStateDir(resolveBaseDir(fixture.config), fixture.teamRunId) + await access(runtimeStateDirectory).then( + () => { throw new Error(`expected ${runtimeStateDirectory} to be removed`) }, + () => undefined, + ) + }) + + test("force removes lead member worktree if present", async () => { + // given + const fixture = await createFixture() + temporaryDirectories.push(fixture.baseDir) + const leadWorktreePath = path.join(fixture.baseDir, "fixture-worktrees", "lead") + await transitionRuntimeState(fixture.teamRunId, (runtimeState) => ({ + ...runtimeState, + members: runtimeState.members.map((member) => member.name === "lead" + ? { ...member, worktreePath: leadWorktreePath } + : member), + }), fixture.config) + await mkdir(leadWorktreePath, { recursive: true }) + await Promise.all(fixture.worktreePaths.map(async (worktreePath) => { + await mkdir(worktreePath, { recursive: true }) + })) + await updateMemberStatuses(fixture.teamRunId, fixture.config, { + "member-a": "running", + "member-b": "running", + }) + + // when + const result = await deleteTeam(fixture.teamRunId, fixture.config, undefined, undefined, { force: true }) + + // then + expect(result.removedWorktrees.sort()).toEqual([leadWorktreePath, ...fixture.worktreePaths].sort()) + await access(leadWorktreePath).then( + () => { throw new Error(`expected ${leadWorktreePath} to be removed`) }, + () => undefined, + ) + }) + + test("force continues cleanup when removeTeamLayout throws", async () => { + // given + const fixture = await createFixture() + temporaryDirectories.push(fixture.baseDir) + const transitionedStatuses: string[] = [] + const originalTransitionRuntimeState = runtimeStateStore.transitionRuntimeState + spyOn(runtimeStateStore, "transitionRuntimeState").mockImplementation(async (teamRunId, transition, config) => { + const currentRuntimeState = await runtimeStateStore.loadRuntimeState(teamRunId, config) + transitionedStatuses.push(transition(currentRuntimeState).status) + return await originalTransitionRuntimeState(teamRunId, transition, config) + }) + spyOn(layoutModule, "canVisualize").mockReturnValue(true) + spyOn(layoutModule, "removeTeamLayout").mockRejectedValue(new Error("layout failed")) + const logSpy = spyOn(logger, "log").mockImplementation(() => {}) + await updateMemberStatuses(fixture.teamRunId, fixture.config, { + "member-a": "running", + "member-b": "idle", + }) + await Promise.all(fixture.worktreePaths.map(async (worktreePath) => { + await mkdir(worktreePath, { recursive: true }) + })) + + // when + const result = await deleteTeam( + fixture.teamRunId, + fixture.config, + { getServerUrl: () => "http://localhost" } as never, + undefined, + { force: true }, + ) + + // then + expect(result.removedLayout).toBe(true) + expect(transitionedStatuses).toContain("deleted") + expect(logSpy).toHaveBeenCalledWith("team delete layout cleanup failed", { + teamRunId: fixture.teamRunId, + error: "layout failed", + }) + const runtimeStateDirectory = getRuntimeStateDir(resolveBaseDir(fixture.config), fixture.teamRunId) + await access(runtimeStateDirectory).then( + () => { throw new Error(`expected ${runtimeStateDirectory} to be removed`) }, + () => undefined, + ) + }) + + test("cancels team background tasks before deleting when force=true", async () => { + // given + const fixture = await createFixture() + temporaryDirectories.push(fixture.baseDir) + await updateMemberStatuses(fixture.teamRunId, fixture.config, { + "member-a": "running", + "member-b": "idle", + }) + const runtimeStatusesDuringCancellation: Array<{ teamStatus: string; memberStatuses: string[] }> = [] + const cancelTaskMock = mock(async () => { + const runtimeState = await loadRuntimeState(fixture.teamRunId, fixture.config) + runtimeStatusesDuringCancellation.push({ + teamStatus: runtimeState.status, + memberStatuses: runtimeState.members + .filter((member) => member.agentType !== "leader") + .map((member) => member.status), + }) + return true + }) + const bgMgr = { + getTasksByParentSession: () => [ + { id: "team-task-a", sessionID: "session-a", parentMessageID: `team-create:${fixture.teamRunId}:member-a` }, + { id: "team-task-b", sessionID: "session-b", parentMessageID: `team-create:${fixture.teamRunId}:member-b` }, + ], + cancelTask: cancelTaskMock, + } + + // when + await deleteTeam(fixture.teamRunId, fixture.config, undefined, bgMgr as never, { force: true }) + + // then + expect(cancelTaskMock).toHaveBeenCalledTimes(2) + expect(runtimeStatusesDuringCancellation).toEqual([ + { teamStatus: "active", memberStatuses: ["running", "idle"] }, + { teamStatus: "active", memberStatuses: ["running", "idle"] }, + ]) + }) + + test("blocks mailbox writes while the team is deleting", async () => { + // given + const fixture = await createFixture() + temporaryDirectories.push(fixture.baseDir) + await updateMemberStatuses(fixture.teamRunId, fixture.config, { + "member-a": "shutdown_approved", + "member-b": "shutdown_approved", + }) + await transitionRuntimeState(fixture.teamRunId, (runtimeState) => ({ + ...runtimeState, + status: "deleting", + }), fixture.config) + + // when + const result = sendMessage( + createTestMessage(), + fixture.teamRunId, + fixture.config, + { isLead: true, activeMembers: ["lead", "member-a", "member-b"] }, + ) + + // then + await result.then( + () => { throw new Error("expected sendMessage to reject") }, + (error: unknown) => { + if (!(error instanceof Error)) throw error + expect(error.message).toBe("team is deleting") + }, + ) + }) +}) diff --git a/src/features/team-mode/team-runtime/shutdown.ts b/src/features/team-mode/team-runtime/shutdown.ts new file mode 100644 index 000000000..920dd04ba --- /dev/null +++ b/src/features/team-mode/team-runtime/shutdown.ts @@ -0,0 +1,146 @@ +import type { TeamModeConfig } from "../../../config/schema/team-mode" +import { sendMessage } from "../team-mailbox/send" +import { loadRuntimeState, transitionRuntimeState } from "../team-state-store/store" +import { + createSendContext, + createShutdownMessage, + findLatestShutdownRequestIndex, + getLeadMemberName, + getRuntimeMember, +} from "./shutdown-helpers" +export { deleteTeam } from "./delete-team" + +export async function requestShutdownOfMember( + teamRunId: string, + targetMemberName: string, + requesterName: string, + config: TeamModeConfig, +): Promise { + const runtimeState = await loadRuntimeState(teamRunId, config) + getRuntimeMember(runtimeState, targetMemberName) + getRuntimeMember(runtimeState, requesterName) + + const existingRequestIndex = findLatestShutdownRequestIndex(runtimeState, targetMemberName, requesterName) + const existingRequest = existingRequestIndex >= 0 + ? runtimeState.shutdownRequests[existingRequestIndex] + : undefined + if (existingRequest && existingRequest.approvedAt === undefined && existingRequest.rejectedAt === undefined) { + return + } + + await sendMessage( + createShutdownMessage(requesterName, targetMemberName, "shutdown_request", ""), + teamRunId, + config, + createSendContext(runtimeState, requesterName), + ) + + await transitionRuntimeState(teamRunId, (currentRuntimeState) => { + const duplicateRequestIndex = findLatestShutdownRequestIndex(currentRuntimeState, targetMemberName, requesterName) + const duplicateRequest = duplicateRequestIndex >= 0 + ? currentRuntimeState.shutdownRequests[duplicateRequestIndex] + : undefined + if (duplicateRequest && duplicateRequest.approvedAt === undefined && duplicateRequest.rejectedAt === undefined) { + return currentRuntimeState + } + + return { + ...currentRuntimeState, + shutdownRequests: [ + ...currentRuntimeState.shutdownRequests, + { memberId: targetMemberName, requesterName, requestedAt: Date.now() }, + ], + } + }, config) +} + +export async function approveShutdown( + teamRunId: string, + memberName: string, + approverName: string, + config: TeamModeConfig, +): Promise { + const runtimeState = await loadRuntimeState(teamRunId, config) + getRuntimeMember(runtimeState, approverName) + const shutdownRequestIndex = findLatestShutdownRequestIndex(runtimeState, memberName) + if (shutdownRequestIndex < 0) { + throw new Error(`shutdown request missing for '${memberName}'`) + } + + const existingRequest = runtimeState.shutdownRequests[shutdownRequestIndex] + if (existingRequest?.approvedAt !== undefined) { + return + } + + const updatedRuntimeState = await transitionRuntimeState(teamRunId, (currentRuntimeState) => { + const currentRequestIndex = findLatestShutdownRequestIndex(currentRuntimeState, memberName) + if (currentRequestIndex < 0) { + throw new Error(`shutdown request missing for '${memberName}'`) + } + + const currentRequest = currentRuntimeState.shutdownRequests[currentRequestIndex] + if (!currentRequest || currentRequest.approvedAt !== undefined) { + return currentRuntimeState + } + + return { + ...currentRuntimeState, + members: currentRuntimeState.members.map((member) => { + if (member.name !== memberName || member.status === "completed" || member.status === "errored") { + return member + } + + return { ...member, status: "shutdown_approved" } + }), + shutdownRequests: currentRuntimeState.shutdownRequests.map((shutdownRequest, index) => index === currentRequestIndex + ? { ...shutdownRequest, approvedAt: Date.now() } + : shutdownRequest), + } + }, config) + + await sendMessage( + createShutdownMessage(approverName, getLeadMemberName(updatedRuntimeState), "shutdown_approved", memberName), + teamRunId, + config, + createSendContext(updatedRuntimeState, approverName), + ) +} + +export async function rejectShutdown( + teamRunId: string, + memberName: string, + reason: string, + config: TeamModeConfig, +): Promise { + const runtimeState = await loadRuntimeState(teamRunId, config) + const shutdownRequestIndex = findLatestShutdownRequestIndex(runtimeState, memberName) + if (shutdownRequestIndex < 0) { + throw new Error(`shutdown request missing for '${memberName}'`) + } + + const shutdownRequest = runtimeState.shutdownRequests[shutdownRequestIndex] + if (shutdownRequest.rejectedAt !== undefined && shutdownRequest.rejectedReason === reason) { + return + } + + await sendMessage( + createShutdownMessage(memberName, shutdownRequest.requesterName, "shutdown_rejected", reason), + teamRunId, + config, + createSendContext(runtimeState, memberName), + ) + + await transitionRuntimeState(teamRunId, (currentRuntimeState) => { + const currentRequestIndex = findLatestShutdownRequestIndex(currentRuntimeState, memberName) + if (currentRequestIndex < 0) { + throw new Error(`shutdown request missing for '${memberName}'`) + } + + return { + ...currentRuntimeState, + shutdownRequests: currentRuntimeState.shutdownRequests.map((currentRequest, index) => index === currentRequestIndex + ? { ...currentRequest, rejectedAt: Date.now(), rejectedReason: reason } + : currentRequest), + } + }, config) +}