diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 1d7de903..34be4f2c 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -18,6 +18,7 @@ import { TEAM_DELETE_TEAM, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, + TEAM_GET_AGENT_RUNTIME, TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, @@ -50,6 +51,7 @@ import { TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REPLACE_MEMBERS, TEAM_REQUEST_REVIEW, + TEAM_RESTART_MEMBER, TEAM_RESTORE, TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, @@ -162,6 +164,7 @@ import type { LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, + TeamAgentRuntimeSnapshot, MemberSpawnStatusesSnapshot, MessagesPage, SendMessageRequest, @@ -552,6 +555,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity); ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext); ipcMain.handle(TEAM_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses); + ipcMain.handle(TEAM_GET_AGENT_RUNTIME, handleGetAgentRuntime); + ipcMain.handle(TEAM_RESTART_MEMBER, handleRestartMember); ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask); ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask); ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks); @@ -625,6 +630,8 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_LEAD_ACTIVITY); ipcMain.removeHandler(TEAM_LEAD_CONTEXT); ipcMain.removeHandler(TEAM_MEMBER_SPAWN_STATUSES); + ipcMain.removeHandler(TEAM_GET_AGENT_RUNTIME); + ipcMain.removeHandler(TEAM_RESTART_MEMBER); ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK); ipcMain.removeHandler(TEAM_RESTORE_TASK); ipcMain.removeHandler(TEAM_GET_DELETED_TASKS); @@ -2758,6 +2765,37 @@ async function handleMemberSpawnStatuses( ); } +async function handleGetAgentRuntime( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + return wrapTeamHandler('getAgentRuntime', async () => + getTeamProvisioningService().getTeamAgentRuntimeSnapshot(validated.value!) + ); +} + +async function handleRestartMember( + _event: IpcMainInvokeEvent, + teamName: unknown, + memberName: unknown +): Promise> { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + const validatedMemberName = validateMemberName(memberName); + if (!validatedMemberName.valid) { + return { success: false, error: validatedMemberName.error ?? 'Invalid memberName' }; + } + return wrapTeamHandler('restartMember', async () => + getTeamProvisioningService().restartMember(validatedTeamName.value!, validatedMemberName.value!) + ); +} + async function handleStopTeam( _event: IpcMainInvokeEvent, teamName: unknown diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0e8d2ee8..f3fb5fb8 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -14,6 +14,7 @@ import { getTasksBasePath, getTeamsBasePath, } from '@main/utils/pathDecoder'; +import { isProcessAlive } from '@main/utils/processHealth'; import { killProcessByPid } from '@main/utils/processKill'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { shouldAutoAllow } from '@main/utils/toolApprovalRules'; @@ -168,8 +169,12 @@ import type { PersistedTeamLaunchPhase, PersistedTeamLaunchSummary, TeamChangeEvent, + TeamConfig, TeamCreateRequest, TeamCreateResponse, + TeamAgentRuntimeBackendType, + TeamAgentRuntimeEntry, + TeamAgentRuntimeSnapshot, TeamLaunchAggregateState, TeamLaunchRequest, TeamLaunchResponse, @@ -789,7 +794,9 @@ function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry { } interface LiveTeamAgentRuntimeMetadata { + pid?: number; model?: string; + rssBytes?: number; } function stripWrappedCliFlagValue(raw: string | undefined): string | undefined { @@ -867,6 +874,24 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function waitForPidsToExit( + pids: readonly number[], + opts: { timeoutMs: number; pollMs: number } +): Promise { + if (pids.length === 0) { + return; + } + + const deadline = Date.now() + opts.timeoutMs; + while (Date.now() < deadline) { + const remaining = pids.filter((pid) => isProcessAlive(pid)); + if (remaining.length === 0) { + return; + } + await sleep(opts.pollMs); + } +} + async function tryReadRegularFileUtf8( filePath: string, opts: { timeoutMs: number; maxBytes: number } @@ -1408,6 +1433,52 @@ export function buildAddMemberSpawnMessage( ); } +export function buildRestartMemberSpawnMessage( + teamName: string, + displayName: string, + leadName: string, + member: Pick< + TeamCreateRequest['members'][number], + 'name' | 'role' | 'workflow' | 'providerId' | 'model' | 'effort' + > +): string { + const roleHint = + typeof member.role === 'string' && member.role.trim() + ? ` with role "${member.role.trim()}"` + : ''; + const workflowHint = + typeof member.workflow === 'string' && member.workflow.trim() + ? ` Their workflow: ${member.workflow.trim()}` + : ''; + + const prompt = buildMemberSpawnPrompt( + { + name: member.name, + ...(member.role ? { role: member.role } : {}), + ...(member.workflow ? { workflow: member.workflow } : {}), + ...(member.providerId ? { providerId: member.providerId } : {}), + ...(member.model ? { model: member.model } : {}), + ...(member.effort ? { effort: member.effort } : {}), + }, + displayName, + teamName, + leadName + ); + const providerPart = + member.providerId && member.providerId !== 'anthropic' + ? `, provider="${member.providerId}"` + : ''; + const modelPart = member.model?.trim() ? `, model="${member.model.trim()}"` : ''; + const effortPart = member.effort ? `, effort="${member.effort}"` : ''; + + return ( + `Teammate "${member.name}"${roleHint} was restarted from the UI. ` + + `Please respawn them immediately using the **Agent** tool with team_name="${teamName}", name="${member.name}", subagent_type="general-purpose"${providerPart}${modelPart}${effortPart}, and the exact prompt below. ` + + `This is a restart of an existing persistent teammate, not a new teammate.${workflowHint ? workflowHint : ''}\n\n` + + indentMultiline(prompt, ' ') + ); +} + interface RuntimeBootstrapMemberSpec { name: string; prompt?: string; @@ -2290,6 +2361,7 @@ export class TeamProvisioningService { private static readonly SAME_TEAM_MATCH_WINDOW_MS = 30_000; private static readonly SAME_TEAM_RUN_START_SKEW_MS = 1_000; private static readonly SAME_TEAM_PERSIST_RETRY_MS = 2_000; + private static readonly AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 2_000; private readonly runs = new Map(); private readonly provisioningRunByTeam = new Map(); @@ -2306,6 +2378,10 @@ export class TeamProvisioningService { string, NativeSameTeamFingerprint[] >(); + private readonly agentRuntimeSnapshotCache = new Map< + string, + { expiresAtMs: number; snapshot: TeamAgentRuntimeSnapshot } + >(); private readonly launchStateStore = new TeamLaunchStateStore(); private readonly memberLogsFinder: TeamMemberLogsFinder; private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; @@ -3728,6 +3804,260 @@ export class TeamProvisioningService { }; } + async getTeamAgentRuntimeSnapshot(teamName: string): Promise { + const cached = this.agentRuntimeSnapshotCache.get(teamName); + if (cached && cached.expiresAtMs > Date.now()) { + return cached.snapshot; + } + + const updatedAt = nowIso(); + const runId = this.getTrackedRunId(teamName); + const run = runId ? (this.runs.get(runId) ?? null) : null; + + let configuredMembers: TeamConfig['members'] = []; + try { + configuredMembers = (await this.configReader.getConfig(teamName))?.members ?? []; + } catch { + configuredMembers = []; + } + + const unixProcessRows = this.readUnixProcessTableRows(); + const liveRuntimeByMember = this.getLiveTeamAgentRuntimeMetadata(teamName, unixProcessRows); + const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName); + const snapshotMembers: Record = {}; + + const getPersistedRuntimeMember = ( + memberName: string + ): PersistedRuntimeMemberLike | undefined => { + return persistedRuntimeMembers.find((member) => { + const candidateName = typeof member.name === 'string' ? member.name.trim() : ''; + if (!candidateName) return false; + if (candidateName === memberName) return true; + const parsed = parseNumericSuffixName(candidateName); + return parsed !== null && parsed.suffix >= 2 && parsed.base === memberName; + }); + }; + + const getLiveRuntimeMember = (memberName: string): LiveTeamAgentRuntimeMetadata | undefined => { + let fallback: LiveTeamAgentRuntimeMetadata | undefined; + for (const [candidateName, metadata] of liveRuntimeByMember.entries()) { + if (candidateName === memberName) { + return metadata; + } + const parsed = parseNumericSuffixName(candidateName); + if (parsed !== null && parsed.suffix >= 2 && parsed.base === memberName) { + fallback = metadata; + } + } + return fallback; + }; + + const normalizeBackendType = ( + value: string | undefined, + isLead: boolean + ): TeamAgentRuntimeBackendType | undefined => { + if (isLead) return 'lead'; + const normalized = value?.trim().toLowerCase(); + if (normalized === 'tmux' || normalized === 'iterm2' || normalized === 'in-process') { + return normalized; + } + return normalized ? 'process' : undefined; + }; + + for (const member of configuredMembers) { + const memberName = typeof member?.name === 'string' ? member.name.trim() : ''; + if (!memberName) continue; + + const isLead = isLeadMember({ name: memberName, agentType: member.agentType }); + if (isLead) { + const pid = run?.child?.pid; + const rssBytes = pid ? this.lookupProcessRssBytes(pid, unixProcessRows) : undefined; + const runtimeModel = + run?.request.model?.trim() || + (run?.spawnContext + ? extractCliFlagValue(run.spawnContext.args.join(' '), '--model') + : undefined) || + member.model?.trim() || + undefined; + snapshotMembers[memberName] = { + memberName, + alive: Boolean(pid && !run?.processKilled && !run?.cancelRequested), + restartable: false, + backendType: 'lead', + ...(pid ? { pid } : {}), + ...(runtimeModel ? { runtimeModel } : {}), + ...(rssBytes != null ? { rssBytes } : {}), + updatedAt, + }; + continue; + } + + const persistedRuntimeMember = getPersistedRuntimeMember(memberName); + const liveRuntimeMember = getLiveRuntimeMember(memberName); + const backendType = normalizeBackendType(persistedRuntimeMember?.backendType, false); + const restartable = backendType !== 'in-process'; + const runtimeModel = liveRuntimeMember?.model ?? member.model?.trim() ?? undefined; + + snapshotMembers[memberName] = { + memberName, + alive: Boolean(liveRuntimeMember?.pid), + restartable, + ...(backendType ? { backendType } : {}), + ...(liveRuntimeMember?.pid ? { pid: liveRuntimeMember.pid } : {}), + ...(runtimeModel ? { runtimeModel } : {}), + ...(liveRuntimeMember?.rssBytes != null ? { rssBytes: liveRuntimeMember.rssBytes } : {}), + updatedAt, + }; + } + + const snapshot: TeamAgentRuntimeSnapshot = { + teamName, + updatedAt, + runId: run?.runId ?? null, + members: snapshotMembers, + }; + + this.agentRuntimeSnapshotCache.set(teamName, { + expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, + snapshot, + }); + return snapshot; + } + + async restartMember(teamName: string, memberName: string): Promise { + const runId = this.getAliveRunId(teamName); + if (!runId) { + throw new Error(`Team "${teamName}" is not currently running`); + } + const run = this.runs.get(runId); + if (!run || run.processKilled || run.cancelRequested) { + throw new Error(`Team "${teamName}" is not currently running`); + } + + const config = await this.configReader.getConfig(teamName); + const configuredMembers = config?.members ?? []; + const configuredMember = configuredMembers.find( + (member) => member?.name?.trim() === memberName + ); + if (!configuredMember) { + throw new Error(`Member "${memberName}" is not configured in team "${teamName}"`); + } + if (configuredMember.removedAt) { + throw new Error(`Member "${memberName}" has been removed`); + } + if (isLeadMember({ name: memberName, agentType: configuredMember.agentType })) { + throw new Error('Lead restart is not supported from member controls'); + } + + const persistedRuntimeMembers = this.readPersistedRuntimeMembers(teamName).filter((member) => { + const candidateName = typeof member.name === 'string' ? member.name.trim() : ''; + if (!candidateName) return false; + if (candidateName === memberName) return true; + const parsed = parseNumericSuffixName(candidateName); + return parsed !== null && parsed.suffix >= 2 && parsed.base === memberName; + }); + + const backendTypes = new Set( + persistedRuntimeMembers + .map((member) => member.backendType?.trim().toLowerCase()) + .filter((value): value is string => Boolean(value)) + ); + if (backendTypes.has('in-process')) { + throw new Error( + `Member "${memberName}" uses an in-process runtime and cannot be restarted here` + ); + } + + const unixProcessRows = this.readUnixProcessTableRows(); + const liveRuntimeByMember = this.getLiveTeamAgentRuntimeMetadata(teamName, unixProcessRows); + const livePids = new Set(); + for (const [candidateName, metadata] of liveRuntimeByMember.entries()) { + if (candidateName === memberName) { + if (metadata.pid) livePids.add(metadata.pid); + continue; + } + const parsed = parseNumericSuffixName(candidateName); + if (parsed !== null && parsed.suffix >= 2 && parsed.base === memberName && metadata.pid) { + livePids.add(metadata.pid); + } + } + + for (const persistedRuntimeMember of persistedRuntimeMembers) { + const paneId = + typeof persistedRuntimeMember.tmuxPaneId === 'string' + ? persistedRuntimeMember.tmuxPaneId.trim() + : ''; + const backendType = persistedRuntimeMember.backendType?.trim().toLowerCase(); + if (!paneId || backendType !== 'tmux') { + continue; + } + try { + killTmuxPaneForCurrentPlatformSync(paneId); + logger.info( + `[${teamName}] Killed teammate pane ${memberName} (${paneId}) for manual restart` + ); + } catch (error) { + logger.debug( + `[${teamName}] Failed to kill teammate pane ${memberName} (${paneId}) for manual restart: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + for (const pid of livePids) { + try { + killProcessByPid(pid); + } catch (error) { + logger.debug( + `[${teamName}] Failed to kill teammate process ${memberName} pid=${pid} for manual restart: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + if (livePids.size > 0) { + await waitForPidsToExit(Array.from(livePids), { + timeoutMs: 1_500, + pollMs: 100, + }); + } + + this.agentRuntimeSnapshotCache.delete(teamName); + this.setMemberSpawnStatus(run, memberName, 'offline'); + this.setMemberSpawnStatus(run, memberName, 'spawning'); + this.appendMemberBootstrapDiagnostic(run, memberName, 'manual restart requested from UI'); + + const leadName = + configuredMembers.find((member) => isLeadMember(member))?.name?.trim() || 'team-lead'; + const restartMessage = buildRestartMemberSpawnMessage( + teamName, + config?.name?.trim() || teamName, + leadName, + { + name: memberName, + role: configuredMember.role, + workflow: configuredMember.workflow, + providerId: configuredMember.providerId, + model: configuredMember.model, + effort: configuredMember.effort, + } + ); + + try { + await this.sendMessageToRun(run, restartMessage); + } catch (error) { + this.setMemberSpawnStatus( + run, + memberName, + 'error', + error instanceof Error ? error.message : String(error) + ); + throw error; + } + } + private getMemberLaunchGraceKey(run: ProvisioningRun, memberName: string): string { return `member-launch-grace:${run.runId}:${memberName}`; } @@ -7275,27 +7605,84 @@ export class TeamProvisioningService { return new Set(this.getLiveTeamAgentRuntimeMetadata(teamName).keys()); } - private getLiveTeamAgentRuntimeMetadata( - teamName: string - ): Map { + private readUnixProcessTableRows(): Array<{ + pid: number; + rssBytes?: number; + command: string; + }> { if (process.platform === 'win32') { - return new Map(); + return []; } let output = ''; try { - output = execFileSync('ps', ['-ax', '-o', 'command='], { + output = execFileSync('ps', ['-ax', '-o', 'pid=,rss=,command='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }); } catch { - return new Map(); + return []; } - const teamMarker = `--team-name ${teamName}`; - const metadataByAgent = new Map(); + const rows: Array<{ pid: number; rssBytes?: number; command: string }> = []; for (const line of output.split('\n')) { const trimmed = line.trim(); + if (!trimmed) continue; + const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(trimmed); + if (!match) continue; + const pid = Number.parseInt(match[1], 10); + const rssKb = Number.parseInt(match[2], 10); + const command = match[3]?.trim() ?? ''; + if (!Number.isFinite(pid) || pid <= 0 || command.length === 0) { + continue; + } + rows.push({ + pid, + ...(Number.isFinite(rssKb) && rssKb >= 0 ? { rssBytes: rssKb * 1024 } : {}), + command, + }); + } + return rows; + } + + private lookupProcessRssBytes( + pid: number, + unixProcessRows?: Array<{ pid: number; rssBytes?: number; command: string }> + ): number | undefined { + if (!Number.isFinite(pid) || pid <= 0) { + return undefined; + } + + const cached = unixProcessRows?.find((row) => row.pid === pid); + if (cached) { + return cached.rssBytes; + } + + if (process.platform === 'win32') { + return undefined; + } + + try { + const output = execFileSync('ps', ['-o', 'rss=', '-p', String(pid)], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const rssKb = Number.parseInt(output.trim(), 10); + return Number.isFinite(rssKb) && rssKb >= 0 ? rssKb * 1024 : undefined; + } catch { + return undefined; + } + } + + private getLiveTeamAgentRuntimeMetadata( + teamName: string, + unixProcessRows?: Array<{ pid: number; rssBytes?: number; command: string }> + ): Map { + const teamMarker = `--team-name ${teamName}`; + const metadataByAgent = new Map(); + const rows = unixProcessRows ?? this.readUnixProcessTableRows(); + for (const row of rows) { + const trimmed = row.command.trim(); if (!trimmed.includes(teamMarker)) continue; const match = /--agent-id\s+([^\s@]+)@/.exec(trimmed); if (!match) continue; @@ -7303,7 +7690,9 @@ export class TeamProvisioningService { if (agentName) { const model = extractCliFlagValue(trimmed, '--model'); metadataByAgent.set(agentName, { + pid: row.pid, ...(model ? { model } : {}), + ...(row.rssBytes != null ? { rssBytes: row.rssBytes } : {}), }); } } @@ -8103,6 +8492,7 @@ export class TeamProvisioningService { * Always uses SIGKILL via killTeamProcess() to prevent CLI cleanup. */ stopTeam(teamName: string): void { + this.agentRuntimeSnapshotCache.delete(teamName); this.stopPersistentTeamMembers(teamName); const runId = this.getTrackedRunId(teamName); @@ -10700,6 +11090,7 @@ export class TeamProvisioningService { this.aliveRunByTeam.delete(run.teamName); } if (!hasNewerTrackedRun) { + this.agentRuntimeSnapshotCache.delete(run.teamName); this.leadInboxRelayInFlight.delete(run.teamName); this.relayedLeadInboxMessageIds.delete(run.teamName); this.pendingCrossTeamFirstReplies.delete(run.teamName); diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index d3c06214..f8de826d 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -373,6 +373,12 @@ export const TEAM_LEAD_CONTEXT = 'team:leadContext'; /** Get per-member spawn statuses for a team */ export const TEAM_MEMBER_SPAWN_STATUSES = 'team:memberSpawnStatuses'; +/** Get live per-agent runtime stats for a team */ +export const TEAM_GET_AGENT_RUNTIME = 'team:getAgentRuntime'; + +/** Restart a specific teammate runtime */ +export const TEAM_RESTART_MEMBER = 'team:restartMember'; + /** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */ export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 9a447ab0..d7ed0c30 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -145,6 +145,7 @@ import { TEAM_LEAD_CONTEXT, TEAM_LIST, TEAM_MEMBER_SPAWN_STATUSES, + TEAM_GET_AGENT_RUNTIME, TEAM_PERMANENTLY_DELETE, TEAM_PREPARE_PROVISIONING, TEAM_PROCESS_ALIVE, @@ -156,6 +157,7 @@ import { TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REPLACE_MEMBERS, TEAM_REQUEST_REVIEW, + TEAM_RESTART_MEMBER, TEAM_RESTORE, TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, @@ -265,6 +267,7 @@ import type { LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, + TeamAgentRuntimeSnapshot, MemberSpawnStatusesSnapshot, MessagesPage, NotificationTrigger, @@ -1063,6 +1066,12 @@ const electronAPI: ElectronAPI = { getMemberSpawnStatuses: async (teamName: string) => { return invokeIpcWithResult(TEAM_MEMBER_SPAWN_STATUSES, teamName); }, + getTeamAgentRuntime: async (teamName: string) => { + return invokeIpcWithResult(TEAM_GET_AGENT_RUNTIME, teamName); + }, + restartMember: async (teamName: string, memberName: string) => { + return invokeIpcWithResult(TEAM_RESTART_MEMBER, teamName, memberName); + }, softDeleteTask: async (teamName: string, taskId: string) => { return invokeIpcWithResult(TEAM_SOFT_DELETE_TASK, teamName, taskId); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index f02a6c6b..fd111ba8 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -908,6 +908,17 @@ export class HttpAPIClient implements ElectronAPI { getMemberSpawnStatuses: async () => { return { statuses: {}, runId: null }; }, + getTeamAgentRuntime: async (teamName: string) => { + return { + teamName, + updatedAt: new Date().toISOString(), + runId: null, + members: {}, + }; + }, + restartMember: async (): Promise => { + throw new Error('Member restart is not available in browser mode'); + }, softDeleteTask: async (_teamName: string, _taskId: string): Promise => { // Not available via HTTP client — no-op }, diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 98582a4c..15600135 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -109,6 +109,7 @@ import type { ContextInjection } from '@renderer/types/contextInjection'; import type { Session } from '@renderer/types/data'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { + TeamAgentRuntimeEntry, MemberSpawnStatusEntry, ResolvedTeamMember, TaskRef, @@ -288,7 +289,7 @@ type TeamMemberListBridgeProps = Omit< }; type TeamMemberDetailDialogBridgeProps = Omit< ComponentProps, - 'leadActivity' | 'spawnEntry' + 'leadActivity' | 'spawnEntry' | 'runtimeEntry' >; type TeamSidebarRailBridgeProps = Omit< ComponentProps, @@ -326,6 +327,17 @@ function buildMemberSpawnStatusMap( return map.size > 0 ? map : undefined; } +function buildTeamAgentRuntimeMap( + runtimeSnapshot: Record | undefined +): Map | undefined { + if (!runtimeSnapshot) { + return undefined; + } + + const map = new Map(Object.entries(runtimeSnapshot)); + return map.size > 0 ? map : undefined; +} + const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({ teamName, isTeamProvisioning, @@ -363,6 +375,54 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({ return null; }); +const TEAM_AGENT_RUNTIME_REFRESH_MS = 5_000; + +const TeamAgentRuntimeWatcher = memo(function TeamAgentRuntimeWatcher({ + teamName, + isTeamProvisioning, + isTeamAlive, + isThisTabActive, +}: { + teamName: string; + isTeamProvisioning: boolean; + isTeamAlive?: boolean; + isThisTabActive: boolean; +}): null { + const { leadActivity, fetchTeamAgentRuntime } = useStore( + useShallow((s) => ({ + leadActivity: s.leadActivityByTeam[teamName], + fetchTeamAgentRuntime: s.fetchTeamAgentRuntime, + })) + ); + + useEffect(() => { + if (!isThisTabActive) return; + const shouldWatch = + isTeamProvisioning || + isTeamAlive === true || + leadActivity === 'active' || + leadActivity === 'idle'; + if (!shouldWatch) return; + + void fetchTeamAgentRuntime(teamName); + const timer = window.setInterval(() => { + void fetchTeamAgentRuntime(teamName); + }, TEAM_AGENT_RUNTIME_REFRESH_MS); + return () => { + window.clearInterval(timer); + }; + }, [ + fetchTeamAgentRuntime, + isTeamAlive, + isTeamProvisioning, + isThisTabActive, + leadActivity, + teamName, + ]); + + return null; +}); + const LeadContextWatcher = memo(function LeadContextWatcher({ teamName, tabId, @@ -681,18 +741,24 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({ teamName, ...props }: TeamMemberListBridgeProps): React.JSX.Element { - const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot } = useStore( - useShallow((s) => ({ - leadActivity: s.leadActivityByTeam[teamName], - progress: getCurrentProvisioningProgressForTeam(s, teamName), - memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], - memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], - })) - ); + const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot, runtimeSnapshot } = + useStore( + useShallow((s) => ({ + leadActivity: s.leadActivityByTeam[teamName], + progress: getCurrentProvisioningProgressForTeam(s, teamName), + memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], + memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], + runtimeSnapshot: s.teamAgentRuntimeByTeam[teamName], + })) + ); const memberSpawnStatusMap = useMemo( () => buildMemberSpawnStatusMap(memberSpawnStatuses), [memberSpawnStatuses] ); + const memberRuntimeMap = useMemo( + () => buildTeamAgentRuntimeMap(runtimeSnapshot?.members), + [runtimeSnapshot?.members] + ); const isLaunchSettling = useMemo(() => { if (progress?.state !== 'ready') { return false; @@ -711,6 +777,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({ {...props} leadActivity={leadActivity} memberSpawnStatuses={memberSpawnStatusMap} + memberRuntimeEntries={memberRuntimeMap} isLaunchSettling={isLaunchSettling} /> ); @@ -771,6 +838,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( memberSpawnStatuses, memberSpawnSnapshot, spawnEntry, + runtimeEntry, } = useStore( useShallow((s) => ({ leadActivity: s.leadActivityByTeam[teamName], @@ -779,6 +847,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined, + runtimeEntry: member ? s.teamAgentRuntimeByTeam[teamName]?.members[member.name] : undefined, })) ); const isLaunchSettling = useMemo(() => { @@ -802,6 +871,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( isLaunchSettling={isLaunchSettling} leadActivity={leadActivity} spawnEntry={spawnEntry} + runtimeEntry={runtimeEntry} /> ); }); @@ -1107,6 +1177,7 @@ export const TeamDetailView = ({ lastSendMessageResult, reviewActionError, addMember, + restartMember, removeMember, updateMemberRole, launchTeam, @@ -1152,6 +1223,7 @@ export const TeamDetailView = ({ lastSendMessageResult: s.lastSendMessageResult, reviewActionError: s.reviewActionError, addMember: s.addMember, + restartMember: s.restartMember, removeMember: s.removeMember, updateMemberRole: s.updateMemberRole, launchTeam: s.launchTeam, @@ -1863,6 +1935,14 @@ export const TeamDetailView = ({ isTeamAlive={data?.isAlive} /> ); + const teamAgentRuntimeWatcher = ( + + ); const leadContextWatcher = ( { const name = selectedMember?.name ?? ''; @@ -2555,6 +2636,7 @@ export const TeamDetailView = ({ closeSelectedMemberDialog(); openCreateTaskDialog('', '', name); }} + onRestartMember={(memberName) => restartMember(teamName, memberName)} onTaskClick={(task) => { closeSelectedMemberDialog(); setSelectedTask(task); @@ -2903,6 +2985,7 @@ export const TeamDetailView = ({ return ( <> {spawnStatusWatcher} + {teamAgentRuntimeWatcher} {leadContextWatcher} {renderBody()} diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 258a2357..cdea5bde 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -8,8 +8,17 @@ import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { useMemberStats } from '@renderer/hooks/useMemberStats'; +import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary'; import { isLeadMember } from '@shared/utils/leadDetection'; -import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react'; +import { + BarChart3, + FileText, + ListPlus, + Loader2, + MessageSquare, + RotateCcw, + UserMinus, +} from 'lucide-react'; import { MemberDetailHeader } from './MemberDetailHeader'; import { MemberDetailStats } from './MemberDetailStats'; @@ -23,9 +32,11 @@ import type { InboxMessage, LeadActivityState, MemberSpawnStatusEntry, + TeamAgentRuntimeEntry, ResolvedTeamMember, TeamTaskWithKanban, } from '@shared/types'; +import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; interface MemberDetailDialogProps { open: boolean; @@ -41,11 +52,14 @@ interface MemberDetailDialogProps { isLaunchSettling?: boolean; leadActivity?: LeadActivityState; spawnEntry?: MemberSpawnStatusEntry; + runtimeEntry?: TeamAgentRuntimeEntry; + launchParams?: TeamLaunchParams; onClose: () => void; onSendMessage: () => void; onAssignTask: () => void; onTaskClick: (task: TeamTaskWithKanban) => void; onRemoveMember?: () => void; + onRestartMember?: (memberName: string) => Promise | void; onUpdateRole?: (memberName: string, role: string | undefined) => Promise | void; updatingRole?: boolean; onViewMemberChanges?: (memberName: string, filePath?: string) => void; @@ -65,11 +79,14 @@ export const MemberDetailDialog = ({ isLaunchSettling, leadActivity, spawnEntry, + runtimeEntry, + launchParams, onClose, onSendMessage, onAssignTask, onTaskClick, onRemoveMember, + onRestartMember, onUpdateRole, updatingRole, onViewMemberChanges, @@ -118,12 +135,24 @@ export const MemberDetailDialog = ({ ); const [activeTab, setActiveTab] = useState(initialTab); + const [restarting, setRestarting] = useState(false); + const [restartError, setRestartError] = useState(null); + + const runtimeSummary = useMemo( + () => + member + ? resolveMemberRuntimeSummary(member, launchParams, spawnEntry, runtimeEntry) + : undefined, + [launchParams, member, runtimeEntry, spawnEntry] + ); useEffect(() => { if (!open || !member) { return; } setActiveTab(initialTab); + setRestartError(null); + setRestarting(false); }, [initialTab, member, open]); const { @@ -143,6 +172,7 @@ export const MemberDetailDialog = ({ + {restartError ? ( +
{restartError}
+ ) : runtimeEntry?.pid ? ( +
+ PID {runtimeEntry.pid} +
+ ) : ( +
+ )} {member.removedAt ? ( Removed {new Date(member.removedAt).toLocaleDateString()} ) : ( <> + {onRestartMember && + !isLeadMember(member) && + (isTeamAlive || isTeamProvisioning) && + runtimeEntry?.restartable !== false && ( + + )}
diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 341a19c1..06ede4cf 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -10,6 +10,7 @@ import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { LeadActivityState, + TeamAgentRuntimeEntry, MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban, @@ -21,6 +22,7 @@ interface MemberListProps { taskMap?: Map; pendingRepliesByMember?: Record; memberSpawnStatuses?: Map; + memberRuntimeEntries?: Map; isLaunchSettling?: boolean; isTeamAlive?: boolean; isTeamProvisioning?: boolean; @@ -169,6 +171,30 @@ function areLaunchParamsEquivalent( ); } +function areMemberRuntimeEntriesEquivalent( + left: Map | undefined, + right: Map | undefined +): boolean { + if (left === right) return true; + if (!left || !right) return left === right; + if (left.size !== right.size) return false; + for (const [key, leftEntry] of left) { + const rightEntry = right.get(key); + if ( + leftEntry.memberName !== rightEntry?.memberName || + leftEntry.alive !== rightEntry?.alive || + leftEntry.restartable !== rightEntry?.restartable || + leftEntry.backendType !== rightEntry?.backendType || + leftEntry.pid !== rightEntry?.pid || + leftEntry.runtimeModel !== rightEntry?.runtimeModel || + leftEntry.rssBytes !== rightEntry?.rssBytes + ) { + return false; + } + } + return true; +} + function areMemberListPropsEqual( prev: Readonly, next: Readonly @@ -179,6 +205,7 @@ function areMemberListPropsEqual( areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) && arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) && areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) && + areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) && prev.isLaunchSettling === next.isLaunchSettling && prev.isTeamAlive === next.isTeamAlive && prev.isTeamProvisioning === next.isTeamProvisioning && @@ -193,6 +220,7 @@ export const MemberList = memo(function MemberList({ taskMap, pendingRepliesByMember, memberSpawnStatuses, + memberRuntimeEntries, isLaunchSettling, isTeamAlive, isTeamProvisioning, @@ -240,9 +268,10 @@ export const MemberList = memo(function MemberList({ const buildRuntimeSummary = useCallback( ( member: ResolvedTeamMember, - spawnEntry: MemberSpawnStatusEntry | undefined + spawnEntry: MemberSpawnStatusEntry | undefined, + runtimeEntry: TeamAgentRuntimeEntry | undefined ): string | undefined => { - return resolveMemberRuntimeSummary(member, launchParams, spawnEntry); + return resolveMemberRuntimeSummary(member, launchParams, spawnEntry, runtimeEntry); }, [launchParams] ); @@ -275,6 +304,7 @@ export const MemberList = memo(function MemberList({ reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null; const awaitingReply = isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name]); const spawnEntry = memberSpawnStatuses?.get(member.name); + const runtimeEntry = memberRuntimeEntries?.get(member.name); return ( >; memberSpawnSnapshotsByTeam: Record; + teamAgentRuntimeByTeam: Record; fetchMemberSpawnStatuses: (teamName: string) => Promise; + fetchTeamAgentRuntime: (teamName: string) => Promise; provisioningErrorByTeam: Record; clearProvisioningError: (teamName?: string) => void; /** Per-team launch parameters (model, effort, extended context) — persisted in localStorage. */ @@ -1255,6 +1300,7 @@ export interface TeamSlice { request: AddTaskCommentRequest ) => Promise; addMember: (teamName: string, request: AddMemberRequest) => Promise; + restartMember: (teamName: string, memberName: string) => Promise; removeMember: (teamName: string, memberName: string) => Promise; updateMemberRole: ( teamName: string, @@ -1482,6 +1528,7 @@ export const createTeamSlice: StateCreator = (set, toolHistoryByTeam: {}, memberSpawnStatusesByTeam: {}, memberSpawnSnapshotsByTeam: {}, + teamAgentRuntimeByTeam: {}, provisioningErrorByTeam: {}, clearProvisioningError: (teamName?: string) => set((state) => { @@ -1591,6 +1638,36 @@ export const createTeamSlice: StateCreator = (set, // ignore — spawn statuses are best-effort } }, + fetchTeamAgentRuntime: async (teamName: string) => { + if (!api.teams?.getTeamAgentRuntime) return; + try { + const snapshot = await api.teams.getTeamAgentRuntime(teamName); + set((prev) => { + if (snapshot.runId != null && prev.ignoredRuntimeRunIds[snapshot.runId] === teamName) { + return {}; + } + if ( + snapshot.runId != null && + prev.currentRuntimeRunIdByTeam[teamName] != null && + prev.currentRuntimeRunIdByTeam[teamName] !== snapshot.runId + ) { + return {}; + } + const previousSnapshot = prev.teamAgentRuntimeByTeam[teamName]; + if (areTeamAgentRuntimeSnapshotsEqual(previousSnapshot, snapshot)) { + return {}; + } + return { + teamAgentRuntimeByTeam: { + ...prev.teamAgentRuntimeByTeam, + [teamName]: snapshot, + }, + }; + }); + } catch { + // ignore — runtime snapshots are best-effort + } + }, kanbanFilterQuery: null, globalTaskDetail: null, pendingMemberProfile: null, @@ -2875,6 +2952,14 @@ export const createTeamSlice: StateCreator = (set, await get().refreshTeamData(teamName); }, + restartMember: async (teamName: string, memberName: string) => { + await unwrapIpc('team:restartMember', () => api.teams.restartMember(teamName, memberName)); + await Promise.all([ + get().fetchMemberSpawnStatuses(teamName), + get().fetchTeamAgentRuntime(teamName), + ]); + }, + removeMember: async (teamName: string, memberName: string) => { await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName)); await get().refreshTeamData(teamName); @@ -2918,9 +3003,15 @@ export const createTeamSlice: StateCreator = (set, const nextCache = state.teamDataCacheByName[teamName] ? { ...state.teamDataCacheByName } : null; + const nextRuntime = state.teamAgentRuntimeByTeam[teamName] + ? { ...state.teamAgentRuntimeByTeam } + : null; if (nextCache) { delete nextCache[teamName]; } + if (nextRuntime) { + delete nextRuntime[teamName]; + } if (state.selectedTeamName === teamName) { return { selectedTeamName: null, @@ -2928,9 +3019,13 @@ export const createTeamSlice: StateCreator = (set, selectedTeamLoading: false, selectedTeamError: null, ...(nextCache ? { teamDataCacheByName: nextCache } : {}), + ...(nextRuntime ? { teamAgentRuntimeByTeam: nextRuntime } : {}), }; } - return nextCache ? { teamDataCacheByName: nextCache } : {}; + return { + ...(nextCache ? { teamDataCacheByName: nextCache } : {}), + ...(nextRuntime ? { teamAgentRuntimeByTeam: nextRuntime } : {}), + }; }); await get().fetchTeams(); await get().fetchAllTasks(); @@ -2939,14 +3034,23 @@ export const createTeamSlice: StateCreator = (set, restoreTeam: async (teamName: string) => { await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName)); set((state) => { - if (!state.teamDataCacheByName[teamName]) { + const hasCache = Boolean(state.teamDataCacheByName[teamName]); + const hasRuntime = Boolean(state.teamAgentRuntimeByTeam[teamName]); + if (!hasCache && !hasRuntime) { return {}; } - const nextCache = { ...state.teamDataCacheByName }; - delete nextCache[teamName]; - return { - teamDataCacheByName: nextCache, - }; + const nextState: Partial = {}; + if (hasCache) { + const nextCache = { ...state.teamDataCacheByName }; + delete nextCache[teamName]; + nextState.teamDataCacheByName = nextCache; + } + if (hasRuntime) { + const nextRuntime = { ...state.teamAgentRuntimeByTeam }; + delete nextRuntime[teamName]; + nextState.teamAgentRuntimeByTeam = nextRuntime; + } + return nextState; }); await get().fetchTeams(); await get().fetchAllTasks(); @@ -2956,17 +3060,21 @@ export const createTeamSlice: StateCreator = (set, await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName)); const state = get(); const nextCache = { ...state.teamDataCacheByName }; + const nextRuntime = { ...state.teamAgentRuntimeByTeam }; delete nextCache[teamName]; + delete nextRuntime[teamName]; if (state.selectedTeamName === teamName) { set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null, teamDataCacheByName: nextCache, + teamAgentRuntimeByTeam: nextRuntime, }); - } else if (state.teamDataCacheByName[teamName]) { + } else if (state.teamDataCacheByName[teamName] || state.teamAgentRuntimeByTeam[teamName]) { set({ teamDataCacheByName: nextCache, + teamAgentRuntimeByTeam: nextRuntime, }); } await get().fetchTeams(); @@ -3000,6 +3108,8 @@ export const createTeamSlice: StateCreator = (set, delete nextSpawnStatuses[request.teamName]; const nextSpawnSnapshots = { ...state.memberSpawnSnapshotsByTeam }; delete nextSpawnSnapshots[request.teamName]; + const nextRuntime = { ...state.teamAgentRuntimeByTeam }; + delete nextRuntime[request.teamName]; const nextActiveTools = { ...state.activeToolsByTeam }; delete nextActiveTools[request.teamName]; const nextFinishedVisible = { ...state.finishedVisibleByTeam }; @@ -3033,6 +3143,7 @@ export const createTeamSlice: StateCreator = (set, provisioningErrorByTeam: nextErrors, memberSpawnStatusesByTeam: nextSpawnStatuses, memberSpawnSnapshotsByTeam: nextSpawnSnapshots, + teamAgentRuntimeByTeam: nextRuntime, activeToolsByTeam: nextActiveTools, finishedVisibleByTeam: nextFinishedVisible, toolHistoryByTeam: nextToolHistory, @@ -3198,6 +3309,8 @@ export const createTeamSlice: StateCreator = (set, delete nextSpawnStatuses[request.teamName]; const nextSpawnSnapshots = { ...state.memberSpawnSnapshotsByTeam }; delete nextSpawnSnapshots[request.teamName]; + const nextRuntime = { ...state.teamAgentRuntimeByTeam }; + delete nextRuntime[request.teamName]; const nextActiveTools = { ...state.activeToolsByTeam }; delete nextActiveTools[request.teamName]; const nextFinishedVisible = { ...state.finishedVisibleByTeam }; @@ -3231,6 +3344,7 @@ export const createTeamSlice: StateCreator = (set, provisioningErrorByTeam: nextErrors, memberSpawnStatusesByTeam: nextSpawnStatuses, memberSpawnSnapshotsByTeam: nextSpawnSnapshots, + teamAgentRuntimeByTeam: nextRuntime, activeToolsByTeam: nextActiveTools, finishedVisibleByTeam: nextFinishedVisible, toolHistoryByTeam: nextToolHistory, @@ -3392,9 +3506,11 @@ export const createTeamSlice: StateCreator = (set, const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam }; const nextSpawnSnapshots = { ...state.memberSpawnSnapshotsByTeam }; + const nextRuntime = { ...state.teamAgentRuntimeByTeam }; if (isCanonicalRun) { delete nextSpawnStatuses[existing.teamName]; delete nextSpawnSnapshots[existing.teamName]; + delete nextRuntime[existing.teamName]; } const nextActiveTools = { ...state.activeToolsByTeam }; const nextFinishedVisible = { ...state.finishedVisibleByTeam }; @@ -3411,6 +3527,7 @@ export const createTeamSlice: StateCreator = (set, currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam, memberSpawnStatusesByTeam: nextSpawnStatuses, memberSpawnSnapshotsByTeam: nextSpawnSnapshots, + teamAgentRuntimeByTeam: nextRuntime, activeToolsByTeam: nextActiveTools, finishedVisibleByTeam: nextFinishedVisible, toolHistoryByTeam: nextToolHistory, @@ -3540,11 +3657,16 @@ export const createTeamSlice: StateCreator = (set, set((prev) => { const next = { ...prev.memberSpawnStatusesByTeam }; const nextSnapshots = { ...prev.memberSpawnSnapshotsByTeam }; + const nextRuntime = { ...prev.teamAgentRuntimeByTeam }; const currentStatuses = next[progress.teamName]; if (!currentStatuses) { + if (progress.state !== 'ready') { + delete nextRuntime[progress.teamName]; + } return { memberSpawnStatusesByTeam: next, memberSpawnSnapshotsByTeam: nextSnapshots, + teamAgentRuntimeByTeam: nextRuntime, }; } if (progress.state === 'ready') { @@ -3552,6 +3674,7 @@ export const createTeamSlice: StateCreator = (set, return { memberSpawnStatusesByTeam: next, memberSpawnSnapshotsByTeam: nextSnapshots, + teamAgentRuntimeByTeam: nextRuntime, }; } const retainedStatuses = Object.fromEntries( @@ -3563,9 +3686,11 @@ export const createTeamSlice: StateCreator = (set, delete next[progress.teamName]; delete nextSnapshots[progress.teamName]; } + delete nextRuntime[progress.teamName]; return { memberSpawnStatusesByTeam: next, memberSpawnSnapshotsByTeam: nextSnapshots, + teamAgentRuntimeByTeam: nextRuntime, }; }); } diff --git a/src/renderer/utils/memberRuntimeSummary.ts b/src/renderer/utils/memberRuntimeSummary.ts index b8246167..45626882 100644 --- a/src/renderer/utils/memberRuntimeSummary.ts +++ b/src/renderer/utils/memberRuntimeSummary.ts @@ -1,8 +1,14 @@ import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector'; +import { formatBytes } from '@renderer/utils/formatters'; import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider'; import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; -import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamProviderId } from '@shared/types'; +import type { + MemberSpawnStatusEntry, + ResolvedTeamMember, + TeamAgentRuntimeEntry, + TeamProviderId, +} from '@shared/types'; function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): boolean { if (!spawnEntry) { @@ -20,22 +26,27 @@ function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): export function resolveMemberRuntimeSummary( member: ResolvedTeamMember, launchParams: TeamLaunchParams | undefined, - spawnEntry: MemberSpawnStatusEntry | undefined + spawnEntry: MemberSpawnStatusEntry | undefined, + runtimeEntry?: TeamAgentRuntimeEntry ): string | undefined { const configuredProvider: TeamProviderId = member.providerId ?? launchParams?.providerId ?? 'anthropic'; const configuredModel = member.model?.trim() || launchParams?.model?.trim() || ''; const configuredEffort = member.effort ?? launchParams?.effort; - const runtimeModel = spawnEntry?.runtimeModel?.trim(); + const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim(); + const memorySuffix = + typeof runtimeEntry?.rssBytes === 'number' && runtimeEntry.rssBytes > 0 + ? ` · ${formatBytes(runtimeEntry.rssBytes)}` + : ''; if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) { const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider; - return formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort); + return `${formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort)}${memorySuffix}`; } if (isMemberLaunchPending(spawnEntry)) { return undefined; } - return formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort); + return `${formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort)}${memorySuffix}`; } diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index d8ffaf2b..283f7ef0 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -55,6 +55,7 @@ import type { LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, + TeamAgentRuntimeSnapshot, MemberSpawnStatusesSnapshot, MessagesPage, ProjectBranchChangeEvent, @@ -532,6 +533,8 @@ export interface TeamsAPI { getLeadActivity: (teamName: string) => Promise; getLeadContext: (teamName: string) => Promise; getMemberSpawnStatuses: (teamName: string) => Promise; + getTeamAgentRuntime: (teamName: string) => Promise; + restartMember: (teamName: string, memberName: string) => Promise; softDeleteTask: (teamName: string, taskId: string) => Promise; restoreTask: (teamName: string, taskId: string) => Promise; getDeletedTasks: (teamName: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index c3538f8a..3a6d69d1 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -870,6 +870,26 @@ export interface MemberSpawnStatusesSnapshot { export type MemberSpawnLivenessSource = 'heartbeat' | 'process'; +export type TeamAgentRuntimeBackendType = 'lead' | 'tmux' | 'iterm2' | 'in-process' | 'process'; + +export interface TeamAgentRuntimeEntry { + memberName: string; + alive: boolean; + restartable: boolean; + backendType?: TeamAgentRuntimeBackendType; + pid?: number; + runtimeModel?: string; + rssBytes?: number; + updatedAt: string; +} + +export interface TeamAgentRuntimeSnapshot { + teamName: string; + updatedAt: string; + runId: string | null; + members: Record; +} + export interface TeamChangeEvent { type: | 'config' diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 597b31bc..f465da8b 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -41,6 +41,7 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { import { buildAddMemberSpawnMessage, + buildRestartMemberSpawnMessage, TeamProvisioningService, } from '@main/services/team/TeamProvisioningService'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; @@ -279,6 +280,21 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => await svc.cancelProvisioning(runId); }); + it('restart teammate message keeps the exact teammate identity and avoids duplicate semantics', () => { + const message = buildRestartMemberSpawnMessage('forge-labs', 'Forge Labs', 'lead', { + name: 'alice', + role: 'Reviewer', + providerId: 'codex', + model: 'gpt-5.4-mini', + effort: 'medium', + }); + + expect(message).toContain('Teammate "alice" with role "Reviewer" was restarted from the UI.'); + expect(message).toContain('team_name="forge-labs", name="alice"'); + expect(message).toContain('provider="codex", model="gpt-5.4-mini", effort="medium"'); + expect(message).toContain('This is a restart of an existing persistent teammate, not a new teammate.'); + }); + it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => { vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); const { child } = createFakeChild(); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index e006ac9d..5005bc7c 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -13,11 +13,13 @@ const hoisted = vi.hoisted(() => ({ createTeam: vi.fn(), getProvisioningStatus: vi.fn(), getMemberSpawnStatuses: vi.fn(), + getTeamAgentRuntime: vi.fn(), cancelProvisioning: vi.fn(), deleteTeam: vi.fn(), restoreTeam: vi.fn(), permanentlyDeleteTeam: vi.fn(), sendMessage: vi.fn(), + restartMember: vi.fn(), requestReview: vi.fn(), updateKanban: vi.fn(), invalidateTaskChangeSummaries: vi.fn(), @@ -32,11 +34,13 @@ vi.mock('@renderer/api', () => ({ createTeam: hoisted.createTeam, getProvisioningStatus: hoisted.getProvisioningStatus, getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses, + getTeamAgentRuntime: hoisted.getTeamAgentRuntime, cancelProvisioning: hoisted.cancelProvisioning, deleteTeam: hoisted.deleteTeam, restoreTeam: hoisted.restoreTeam, permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam, sendMessage: hoisted.sendMessage, + restartMember: hoisted.restartMember, requestReview: hoisted.requestReview, updateKanban: hoisted.updateKanban, onProvisioningProgress: hoisted.onProvisioningProgress, @@ -125,6 +129,27 @@ function createMemberSpawnSnapshot(overrides: Record = {}) { }; } +function createRuntimeSnapshot(overrides: Record = {}) { + return { + teamName: 'my-team', + updatedAt: '2026-03-12T10:00:00.000Z', + runId: 'runtime-run', + members: { + alice: { + memberName: 'alice', + alive: true, + restartable: true, + backendType: 'tmux', + pid: 4242, + runtimeModel: 'gpt-5.4-mini', + rssBytes: 256 * 1024 * 1024, + updatedAt: '2026-03-12T10:00:00.000Z', + }, + }, + ...overrides, + }; +} + describe('teamSlice actions', () => { beforeEach(() => { vi.clearAllMocks(); @@ -153,10 +178,12 @@ describe('teamSlice actions', () => { updatedAt: new Date().toISOString(), }); hoisted.getMemberSpawnStatuses.mockResolvedValue({ statuses: {}, runId: null }); + hoisted.getTeamAgentRuntime.mockResolvedValue(createRuntimeSnapshot({ runId: null, members: {} })); hoisted.cancelProvisioning.mockResolvedValue(undefined); hoisted.deleteTeam.mockResolvedValue(undefined); hoisted.restoreTeam.mockResolvedValue(undefined); hoisted.permanentlyDeleteTeam.mockResolvedValue(undefined); + hoisted.restartMember.mockResolvedValue(undefined); }); it('maps inbox verify failure to user-friendly text', async () => { @@ -632,6 +659,64 @@ describe('teamSlice actions', () => { expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined(); }); + it('stores runtime snapshots and suppresses semantic no-op refreshes', async () => { + const store = createSliceStore(); + const snapshot = createRuntimeSnapshot(); + hoisted.getTeamAgentRuntime.mockResolvedValue(snapshot); + + await store.getState().fetchTeamAgentRuntime('my-team'); + const firstSnapshot = store.getState().teamAgentRuntimeByTeam['my-team']; + + expect(firstSnapshot).toEqual(snapshot); + + hoisted.getTeamAgentRuntime.mockResolvedValue({ + ...snapshot, + updatedAt: '2026-03-12T10:00:05.000Z', + members: { + alice: { + ...snapshot.members.alice, + updatedAt: '2026-03-12T10:00:05.000Z', + }, + }, + }); + + await store.getState().fetchTeamAgentRuntime('my-team'); + + expect(store.getState().teamAgentRuntimeByTeam['my-team']).toBe(firstSnapshot); + }); + + it('restartMember refreshes spawn statuses and runtime snapshot', async () => { + const store = createSliceStore(); + hoisted.getMemberSpawnStatuses.mockResolvedValue({ + statuses: { + alice: createMemberSpawnStatus({ status: 'spawning', launchState: 'starting' }), + }, + runId: 'runtime-run', + }); + hoisted.getTeamAgentRuntime.mockResolvedValue(createRuntimeSnapshot()); + + await store.getState().restartMember('my-team', 'alice'); + + expect(hoisted.restartMember).toHaveBeenCalledWith('my-team', 'alice'); + expect(store.getState().memberSpawnStatusesByTeam['my-team']).toEqual({ + alice: expect.objectContaining({ status: 'spawning', launchState: 'starting' }), + }); + expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(createRuntimeSnapshot()); + }); + + it('clears stale runtime snapshots on delete', async () => { + const store = createSliceStore(); + store.setState({ + teamAgentRuntimeByTeam: { + 'my-team': createRuntimeSnapshot(), + }, + }); + + await store.getState().deleteTeam('my-team'); + + expect(store.getState().teamAgentRuntimeByTeam['my-team']).toBeUndefined(); + }); + describe('refreshTeamData provisioning safety', () => { it('does not set fatal error on TEAM_PROVISIONING', async () => { const store = createSliceStore(); diff --git a/test/renderer/utils/memberRuntimeSummary.test.ts b/test/renderer/utils/memberRuntimeSummary.test.ts index ebf5ecdc..5c04ea15 100644 --- a/test/renderer/utils/memberRuntimeSummary.test.ts +++ b/test/renderer/utils/memberRuntimeSummary.test.ts @@ -63,4 +63,21 @@ describe('resolveMemberRuntimeSummary', () => { expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBe('5.4 Mini · Medium'); }); + + it('appends runtime memory when a live process snapshot is available', () => { + const member = createMember({ model: 'gpt-5.4-mini' }); + const runtimeEntry = { + memberName: 'alice', + alive: true, + restartable: true, + pid: 4242, + runtimeModel: 'gpt-5.4-mini', + rssBytes: 256 * 1024 * 1024, + updatedAt: '2026-04-18T18:00:00.000Z', + }; + + expect(resolveMemberRuntimeSummary(member, undefined, undefined, runtimeEntry)).toBe( + '5.4 Mini · Medium · 256.0 MB' + ); + }); });