feat(team): add member runtime diagnostics and restart controls

This commit is contained in:
777genius 2026-04-18 19:17:58 +03:00
parent b7547e5d87
commit 571b7fb0f5
16 changed files with 958 additions and 34 deletions

View file

@ -18,6 +18,7 @@ import {
TEAM_DELETE_TEAM, TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS, TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS, TEAM_GET_ATTACHMENTS,
TEAM_GET_AGENT_RUNTIME,
TEAM_GET_CLAUDE_LOGS, TEAM_GET_CLAUDE_LOGS,
TEAM_GET_DATA, TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS, TEAM_GET_DELETED_TASKS,
@ -50,6 +51,7 @@ import {
TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REMOVE_TASK_RELATIONSHIP,
TEAM_REPLACE_MEMBERS, TEAM_REPLACE_MEMBERS,
TEAM_REQUEST_REVIEW, TEAM_REQUEST_REVIEW,
TEAM_RESTART_MEMBER,
TEAM_RESTORE, TEAM_RESTORE,
TEAM_RESTORE_TASK, TEAM_RESTORE_TASK,
TEAM_SAVE_TASK_ATTACHMENT, TEAM_SAVE_TASK_ATTACHMENT,
@ -162,6 +164,7 @@ import type {
LeadContextUsageSnapshot, LeadContextUsageSnapshot,
MemberFullStats, MemberFullStats,
MemberLogSummary, MemberLogSummary,
TeamAgentRuntimeSnapshot,
MemberSpawnStatusesSnapshot, MemberSpawnStatusesSnapshot,
MessagesPage, MessagesPage,
SendMessageRequest, SendMessageRequest,
@ -552,6 +555,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity); ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext); ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext);
ipcMain.handle(TEAM_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses); 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_SOFT_DELETE_TASK, handleSoftDeleteTask);
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask); ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks); 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_ACTIVITY);
ipcMain.removeHandler(TEAM_LEAD_CONTEXT); ipcMain.removeHandler(TEAM_LEAD_CONTEXT);
ipcMain.removeHandler(TEAM_MEMBER_SPAWN_STATUSES); 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_SOFT_DELETE_TASK);
ipcMain.removeHandler(TEAM_RESTORE_TASK); ipcMain.removeHandler(TEAM_RESTORE_TASK);
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS); ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
@ -2758,6 +2765,37 @@ async function handleMemberSpawnStatuses(
); );
} }
async function handleGetAgentRuntime(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<TeamAgentRuntimeSnapshot>> {
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<IpcResult<void>> {
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( async function handleStopTeam(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown teamName: unknown

View file

@ -14,6 +14,7 @@ import {
getTasksBasePath, getTasksBasePath,
getTeamsBasePath, getTeamsBasePath,
} from '@main/utils/pathDecoder'; } from '@main/utils/pathDecoder';
import { isProcessAlive } from '@main/utils/processHealth';
import { killProcessByPid } from '@main/utils/processKill'; import { killProcessByPid } from '@main/utils/processKill';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { shouldAutoAllow } from '@main/utils/toolApprovalRules'; import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
@ -168,8 +169,12 @@ import type {
PersistedTeamLaunchPhase, PersistedTeamLaunchPhase,
PersistedTeamLaunchSummary, PersistedTeamLaunchSummary,
TeamChangeEvent, TeamChangeEvent,
TeamConfig,
TeamCreateRequest, TeamCreateRequest,
TeamCreateResponse, TeamCreateResponse,
TeamAgentRuntimeBackendType,
TeamAgentRuntimeEntry,
TeamAgentRuntimeSnapshot,
TeamLaunchAggregateState, TeamLaunchAggregateState,
TeamLaunchRequest, TeamLaunchRequest,
TeamLaunchResponse, TeamLaunchResponse,
@ -789,7 +794,9 @@ function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry {
} }
interface LiveTeamAgentRuntimeMetadata { interface LiveTeamAgentRuntimeMetadata {
pid?: number;
model?: string; model?: string;
rssBytes?: number;
} }
function stripWrappedCliFlagValue(raw: string | undefined): string | undefined { function stripWrappedCliFlagValue(raw: string | undefined): string | undefined {
@ -867,6 +874,24 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
async function waitForPidsToExit(
pids: readonly number[],
opts: { timeoutMs: number; pollMs: number }
): Promise<void> {
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( async function tryReadRegularFileUtf8(
filePath: string, filePath: string,
opts: { timeoutMs: number; maxBytes: number } 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 { interface RuntimeBootstrapMemberSpec {
name: string; name: string;
prompt?: 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_MATCH_WINDOW_MS = 30_000;
private static readonly SAME_TEAM_RUN_START_SKEW_MS = 1_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 SAME_TEAM_PERSIST_RETRY_MS = 2_000;
private static readonly AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 2_000;
private readonly runs = new Map<string, ProvisioningRun>(); private readonly runs = new Map<string, ProvisioningRun>();
private readonly provisioningRunByTeam = new Map<string, string>(); private readonly provisioningRunByTeam = new Map<string, string>();
@ -2306,6 +2378,10 @@ export class TeamProvisioningService {
string, string,
NativeSameTeamFingerprint[] NativeSameTeamFingerprint[]
>(); >();
private readonly agentRuntimeSnapshotCache = new Map<
string,
{ expiresAtMs: number; snapshot: TeamAgentRuntimeSnapshot }
>();
private readonly launchStateStore = new TeamLaunchStateStore(); private readonly launchStateStore = new TeamLaunchStateStore();
private readonly memberLogsFinder: TeamMemberLogsFinder; private readonly memberLogsFinder: TeamMemberLogsFinder;
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
@ -3728,6 +3804,260 @@ export class TeamProvisioningService {
}; };
} }
async getTeamAgentRuntimeSnapshot(teamName: string): Promise<TeamAgentRuntimeSnapshot> {
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<string, TeamAgentRuntimeEntry> = {};
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<void> {
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<number>();
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 { private getMemberLaunchGraceKey(run: ProvisioningRun, memberName: string): string {
return `member-launch-grace:${run.runId}:${memberName}`; return `member-launch-grace:${run.runId}:${memberName}`;
} }
@ -7275,27 +7605,84 @@ export class TeamProvisioningService {
return new Set(this.getLiveTeamAgentRuntimeMetadata(teamName).keys()); return new Set(this.getLiveTeamAgentRuntimeMetadata(teamName).keys());
} }
private getLiveTeamAgentRuntimeMetadata( private readUnixProcessTableRows(): Array<{
teamName: string pid: number;
): Map<string, LiveTeamAgentRuntimeMetadata> { rssBytes?: number;
command: string;
}> {
if (process.platform === 'win32') { if (process.platform === 'win32') {
return new Map(); return [];
} }
let output = ''; let output = '';
try { try {
output = execFileSync('ps', ['-ax', '-o', 'command='], { output = execFileSync('ps', ['-ax', '-o', 'pid=,rss=,command='], {
encoding: 'utf8', encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'], stdio: ['ignore', 'pipe', 'ignore'],
}); });
} catch { } catch {
return new Map(); return [];
} }
const teamMarker = `--team-name ${teamName}`; const rows: Array<{ pid: number; rssBytes?: number; command: string }> = [];
const metadataByAgent = new Map<string, LiveTeamAgentRuntimeMetadata>();
for (const line of output.split('\n')) { for (const line of output.split('\n')) {
const trimmed = line.trim(); 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<string, LiveTeamAgentRuntimeMetadata> {
const teamMarker = `--team-name ${teamName}`;
const metadataByAgent = new Map<string, LiveTeamAgentRuntimeMetadata>();
const rows = unixProcessRows ?? this.readUnixProcessTableRows();
for (const row of rows) {
const trimmed = row.command.trim();
if (!trimmed.includes(teamMarker)) continue; if (!trimmed.includes(teamMarker)) continue;
const match = /--agent-id\s+([^\s@]+)@/.exec(trimmed); const match = /--agent-id\s+([^\s@]+)@/.exec(trimmed);
if (!match) continue; if (!match) continue;
@ -7303,7 +7690,9 @@ export class TeamProvisioningService {
if (agentName) { if (agentName) {
const model = extractCliFlagValue(trimmed, '--model'); const model = extractCliFlagValue(trimmed, '--model');
metadataByAgent.set(agentName, { metadataByAgent.set(agentName, {
pid: row.pid,
...(model ? { model } : {}), ...(model ? { model } : {}),
...(row.rssBytes != null ? { rssBytes: row.rssBytes } : {}),
}); });
} }
} }
@ -8103,6 +8492,7 @@ export class TeamProvisioningService {
* Always uses SIGKILL via killTeamProcess() to prevent CLI cleanup. * Always uses SIGKILL via killTeamProcess() to prevent CLI cleanup.
*/ */
stopTeam(teamName: string): void { stopTeam(teamName: string): void {
this.agentRuntimeSnapshotCache.delete(teamName);
this.stopPersistentTeamMembers(teamName); this.stopPersistentTeamMembers(teamName);
const runId = this.getTrackedRunId(teamName); const runId = this.getTrackedRunId(teamName);
@ -10700,6 +11090,7 @@ export class TeamProvisioningService {
this.aliveRunByTeam.delete(run.teamName); this.aliveRunByTeam.delete(run.teamName);
} }
if (!hasNewerTrackedRun) { if (!hasNewerTrackedRun) {
this.agentRuntimeSnapshotCache.delete(run.teamName);
this.leadInboxRelayInFlight.delete(run.teamName); this.leadInboxRelayInFlight.delete(run.teamName);
this.relayedLeadInboxMessageIds.delete(run.teamName); this.relayedLeadInboxMessageIds.delete(run.teamName);
this.pendingCrossTeamFirstReplies.delete(run.teamName); this.pendingCrossTeamFirstReplies.delete(run.teamName);

View file

@ -373,6 +373,12 @@ export const TEAM_LEAD_CONTEXT = 'team:leadContext';
/** Get per-member spawn statuses for a team */ /** Get per-member spawn statuses for a team */
export const TEAM_MEMBER_SPAWN_STATUSES = 'team:memberSpawnStatuses'; 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) */ /** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask'; export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';

View file

@ -145,6 +145,7 @@ import {
TEAM_LEAD_CONTEXT, TEAM_LEAD_CONTEXT,
TEAM_LIST, TEAM_LIST,
TEAM_MEMBER_SPAWN_STATUSES, TEAM_MEMBER_SPAWN_STATUSES,
TEAM_GET_AGENT_RUNTIME,
TEAM_PERMANENTLY_DELETE, TEAM_PERMANENTLY_DELETE,
TEAM_PREPARE_PROVISIONING, TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE, TEAM_PROCESS_ALIVE,
@ -156,6 +157,7 @@ import {
TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REMOVE_TASK_RELATIONSHIP,
TEAM_REPLACE_MEMBERS, TEAM_REPLACE_MEMBERS,
TEAM_REQUEST_REVIEW, TEAM_REQUEST_REVIEW,
TEAM_RESTART_MEMBER,
TEAM_RESTORE, TEAM_RESTORE,
TEAM_RESTORE_TASK, TEAM_RESTORE_TASK,
TEAM_SAVE_TASK_ATTACHMENT, TEAM_SAVE_TASK_ATTACHMENT,
@ -265,6 +267,7 @@ import type {
LeadContextUsageSnapshot, LeadContextUsageSnapshot,
MemberFullStats, MemberFullStats,
MemberLogSummary, MemberLogSummary,
TeamAgentRuntimeSnapshot,
MemberSpawnStatusesSnapshot, MemberSpawnStatusesSnapshot,
MessagesPage, MessagesPage,
NotificationTrigger, NotificationTrigger,
@ -1063,6 +1066,12 @@ const electronAPI: ElectronAPI = {
getMemberSpawnStatuses: async (teamName: string) => { getMemberSpawnStatuses: async (teamName: string) => {
return invokeIpcWithResult<MemberSpawnStatusesSnapshot>(TEAM_MEMBER_SPAWN_STATUSES, teamName); return invokeIpcWithResult<MemberSpawnStatusesSnapshot>(TEAM_MEMBER_SPAWN_STATUSES, teamName);
}, },
getTeamAgentRuntime: async (teamName: string) => {
return invokeIpcWithResult<TeamAgentRuntimeSnapshot>(TEAM_GET_AGENT_RUNTIME, teamName);
},
restartMember: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<void>(TEAM_RESTART_MEMBER, teamName, memberName);
},
softDeleteTask: async (teamName: string, taskId: string) => { softDeleteTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId); return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
}, },

View file

@ -908,6 +908,17 @@ export class HttpAPIClient implements ElectronAPI {
getMemberSpawnStatuses: async () => { getMemberSpawnStatuses: async () => {
return { statuses: {}, runId: null }; return { statuses: {}, runId: null };
}, },
getTeamAgentRuntime: async (teamName: string) => {
return {
teamName,
updatedAt: new Date().toISOString(),
runId: null,
members: {},
};
},
restartMember: async (): Promise<void> => {
throw new Error('Member restart is not available in browser mode');
},
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => { softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
// Not available via HTTP client — no-op // Not available via HTTP client — no-op
}, },

View file

@ -109,6 +109,7 @@ import type { ContextInjection } from '@renderer/types/contextInjection';
import type { Session } from '@renderer/types/data'; import type { Session } from '@renderer/types/data';
import type { InlineChip } from '@renderer/types/inlineChip'; import type { InlineChip } from '@renderer/types/inlineChip';
import type { import type {
TeamAgentRuntimeEntry,
MemberSpawnStatusEntry, MemberSpawnStatusEntry,
ResolvedTeamMember, ResolvedTeamMember,
TaskRef, TaskRef,
@ -288,7 +289,7 @@ type TeamMemberListBridgeProps = Omit<
}; };
type TeamMemberDetailDialogBridgeProps = Omit< type TeamMemberDetailDialogBridgeProps = Omit<
ComponentProps<typeof MemberDetailDialog>, ComponentProps<typeof MemberDetailDialog>,
'leadActivity' | 'spawnEntry' 'leadActivity' | 'spawnEntry' | 'runtimeEntry'
>; >;
type TeamSidebarRailBridgeProps = Omit< type TeamSidebarRailBridgeProps = Omit<
ComponentProps<typeof TeamSidebarRail>, ComponentProps<typeof TeamSidebarRail>,
@ -326,6 +327,17 @@ function buildMemberSpawnStatusMap(
return map.size > 0 ? map : undefined; return map.size > 0 ? map : undefined;
} }
function buildTeamAgentRuntimeMap(
runtimeSnapshot: Record<string, TeamAgentRuntimeEntry> | undefined
): Map<string, TeamAgentRuntimeEntry> | undefined {
if (!runtimeSnapshot) {
return undefined;
}
const map = new Map<string, TeamAgentRuntimeEntry>(Object.entries(runtimeSnapshot));
return map.size > 0 ? map : undefined;
}
const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
teamName, teamName,
isTeamProvisioning, isTeamProvisioning,
@ -363,6 +375,54 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
return null; 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({ const LeadContextWatcher = memo(function LeadContextWatcher({
teamName, teamName,
tabId, tabId,
@ -681,18 +741,24 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
teamName, teamName,
...props ...props
}: TeamMemberListBridgeProps): React.JSX.Element { }: TeamMemberListBridgeProps): React.JSX.Element {
const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot } = useStore( const { leadActivity, progress, memberSpawnStatuses, memberSpawnSnapshot, runtimeSnapshot } =
useShallow((s) => ({ useStore(
leadActivity: s.leadActivityByTeam[teamName], useShallow((s) => ({
progress: getCurrentProvisioningProgressForTeam(s, teamName), leadActivity: s.leadActivityByTeam[teamName],
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], progress: getCurrentProvisioningProgressForTeam(s, teamName),
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
})) memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
); runtimeSnapshot: s.teamAgentRuntimeByTeam[teamName],
}))
);
const memberSpawnStatusMap = useMemo( const memberSpawnStatusMap = useMemo(
() => buildMemberSpawnStatusMap(memberSpawnStatuses), () => buildMemberSpawnStatusMap(memberSpawnStatuses),
[memberSpawnStatuses] [memberSpawnStatuses]
); );
const memberRuntimeMap = useMemo(
() => buildTeamAgentRuntimeMap(runtimeSnapshot?.members),
[runtimeSnapshot?.members]
);
const isLaunchSettling = useMemo(() => { const isLaunchSettling = useMemo(() => {
if (progress?.state !== 'ready') { if (progress?.state !== 'ready') {
return false; return false;
@ -711,6 +777,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
{...props} {...props}
leadActivity={leadActivity} leadActivity={leadActivity}
memberSpawnStatuses={memberSpawnStatusMap} memberSpawnStatuses={memberSpawnStatusMap}
memberRuntimeEntries={memberRuntimeMap}
isLaunchSettling={isLaunchSettling} isLaunchSettling={isLaunchSettling}
/> />
); );
@ -771,6 +838,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
memberSpawnStatuses, memberSpawnStatuses,
memberSpawnSnapshot, memberSpawnSnapshot,
spawnEntry, spawnEntry,
runtimeEntry,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
leadActivity: s.leadActivityByTeam[teamName], leadActivity: s.leadActivityByTeam[teamName],
@ -779,6 +847,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined, spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
runtimeEntry: member ? s.teamAgentRuntimeByTeam[teamName]?.members[member.name] : undefined,
})) }))
); );
const isLaunchSettling = useMemo(() => { const isLaunchSettling = useMemo(() => {
@ -802,6 +871,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
isLaunchSettling={isLaunchSettling} isLaunchSettling={isLaunchSettling}
leadActivity={leadActivity} leadActivity={leadActivity}
spawnEntry={spawnEntry} spawnEntry={spawnEntry}
runtimeEntry={runtimeEntry}
/> />
); );
}); });
@ -1107,6 +1177,7 @@ export const TeamDetailView = ({
lastSendMessageResult, lastSendMessageResult,
reviewActionError, reviewActionError,
addMember, addMember,
restartMember,
removeMember, removeMember,
updateMemberRole, updateMemberRole,
launchTeam, launchTeam,
@ -1152,6 +1223,7 @@ export const TeamDetailView = ({
lastSendMessageResult: s.lastSendMessageResult, lastSendMessageResult: s.lastSendMessageResult,
reviewActionError: s.reviewActionError, reviewActionError: s.reviewActionError,
addMember: s.addMember, addMember: s.addMember,
restartMember: s.restartMember,
removeMember: s.removeMember, removeMember: s.removeMember,
updateMemberRole: s.updateMemberRole, updateMemberRole: s.updateMemberRole,
launchTeam: s.launchTeam, launchTeam: s.launchTeam,
@ -1863,6 +1935,14 @@ export const TeamDetailView = ({
isTeamAlive={data?.isAlive} isTeamAlive={data?.isAlive}
/> />
); );
const teamAgentRuntimeWatcher = (
<TeamAgentRuntimeWatcher
teamName={teamName}
isTeamProvisioning={isTeamProvisioning}
isTeamAlive={data?.isAlive}
isThisTabActive={isThisTabActive}
/>
);
const leadContextWatcher = ( const leadContextWatcher = (
<LeadContextWatcher <LeadContextWatcher
teamName={teamName} teamName={teamName}
@ -2540,6 +2620,7 @@ export const TeamDetailView = ({
initialActivityFilter={selectedMemberView?.initialActivityFilter} initialActivityFilter={selectedMemberView?.initialActivityFilter}
isTeamAlive={data.isAlive} isTeamAlive={data.isAlive}
isTeamProvisioning={isTeamProvisioning} isTeamProvisioning={isTeamProvisioning}
launchParams={launchParams}
onClose={closeSelectedMemberDialog} onClose={closeSelectedMemberDialog}
onSendMessage={() => { onSendMessage={() => {
const name = selectedMember?.name ?? ''; const name = selectedMember?.name ?? '';
@ -2555,6 +2636,7 @@ export const TeamDetailView = ({
closeSelectedMemberDialog(); closeSelectedMemberDialog();
openCreateTaskDialog('', '', name); openCreateTaskDialog('', '', name);
}} }}
onRestartMember={(memberName) => restartMember(teamName, memberName)}
onTaskClick={(task) => { onTaskClick={(task) => {
closeSelectedMemberDialog(); closeSelectedMemberDialog();
setSelectedTask(task); setSelectedTask(task);
@ -2903,6 +2985,7 @@ export const TeamDetailView = ({
return ( return (
<> <>
{spawnStatusWatcher} {spawnStatusWatcher}
{teamAgentRuntimeWatcher}
{leadContextWatcher} {leadContextWatcher}
{renderBody()} {renderBody()}
</> </>

View file

@ -8,8 +8,17 @@ import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { useMemberStats } from '@renderer/hooks/useMemberStats'; import { useMemberStats } from '@renderer/hooks/useMemberStats';
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
import { isLeadMember } from '@shared/utils/leadDetection'; 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 { MemberDetailHeader } from './MemberDetailHeader';
import { MemberDetailStats } from './MemberDetailStats'; import { MemberDetailStats } from './MemberDetailStats';
@ -23,9 +32,11 @@ import type {
InboxMessage, InboxMessage,
LeadActivityState, LeadActivityState,
MemberSpawnStatusEntry, MemberSpawnStatusEntry,
TeamAgentRuntimeEntry,
ResolvedTeamMember, ResolvedTeamMember,
TeamTaskWithKanban, TeamTaskWithKanban,
} from '@shared/types'; } from '@shared/types';
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
interface MemberDetailDialogProps { interface MemberDetailDialogProps {
open: boolean; open: boolean;
@ -41,11 +52,14 @@ interface MemberDetailDialogProps {
isLaunchSettling?: boolean; isLaunchSettling?: boolean;
leadActivity?: LeadActivityState; leadActivity?: LeadActivityState;
spawnEntry?: MemberSpawnStatusEntry; spawnEntry?: MemberSpawnStatusEntry;
runtimeEntry?: TeamAgentRuntimeEntry;
launchParams?: TeamLaunchParams;
onClose: () => void; onClose: () => void;
onSendMessage: () => void; onSendMessage: () => void;
onAssignTask: () => void; onAssignTask: () => void;
onTaskClick: (task: TeamTaskWithKanban) => void; onTaskClick: (task: TeamTaskWithKanban) => void;
onRemoveMember?: () => void; onRemoveMember?: () => void;
onRestartMember?: (memberName: string) => Promise<void> | void;
onUpdateRole?: (memberName: string, role: string | undefined) => Promise<void> | void; onUpdateRole?: (memberName: string, role: string | undefined) => Promise<void> | void;
updatingRole?: boolean; updatingRole?: boolean;
onViewMemberChanges?: (memberName: string, filePath?: string) => void; onViewMemberChanges?: (memberName: string, filePath?: string) => void;
@ -65,11 +79,14 @@ export const MemberDetailDialog = ({
isLaunchSettling, isLaunchSettling,
leadActivity, leadActivity,
spawnEntry, spawnEntry,
runtimeEntry,
launchParams,
onClose, onClose,
onSendMessage, onSendMessage,
onAssignTask, onAssignTask,
onTaskClick, onTaskClick,
onRemoveMember, onRemoveMember,
onRestartMember,
onUpdateRole, onUpdateRole,
updatingRole, updatingRole,
onViewMemberChanges, onViewMemberChanges,
@ -118,12 +135,24 @@ export const MemberDetailDialog = ({
); );
const [activeTab, setActiveTab] = useState<MemberDetailTab>(initialTab); const [activeTab, setActiveTab] = useState<MemberDetailTab>(initialTab);
const [restarting, setRestarting] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null);
const runtimeSummary = useMemo(
() =>
member
? resolveMemberRuntimeSummary(member, launchParams, spawnEntry, runtimeEntry)
: undefined,
[launchParams, member, runtimeEntry, spawnEntry]
);
useEffect(() => { useEffect(() => {
if (!open || !member) { if (!open || !member) {
return; return;
} }
setActiveTab(initialTab); setActiveTab(initialTab);
setRestartError(null);
setRestarting(false);
}, [initialTab, member, open]); }, [initialTab, member, open]);
const { const {
@ -143,6 +172,7 @@ export const MemberDetailDialog = ({
<DialogHeader className="shrink-0"> <DialogHeader className="shrink-0">
<MemberDetailHeader <MemberDetailHeader
member={member} member={member}
runtimeSummary={runtimeSummary}
isTeamAlive={isTeamAlive} isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning} isTeamProvisioning={isTeamProvisioning}
leadActivity={isLeadMember(member) ? leadActivity : undefined} leadActivity={isLeadMember(member) ? leadActivity : undefined}
@ -232,12 +262,52 @@ export const MemberDetailDialog = ({
</Tabs> </Tabs>
<DialogFooter> <DialogFooter>
{restartError ? (
<div className="mr-auto text-xs text-red-400">{restartError}</div>
) : runtimeEntry?.pid ? (
<div className="mr-auto text-xs text-[var(--color-text-muted)]">
PID {runtimeEntry.pid}
</div>
) : (
<div className="mr-auto" />
)}
{member.removedAt ? ( {member.removedAt ? (
<span className="text-xs text-[var(--color-text-muted)]"> <span className="text-xs text-[var(--color-text-muted)]">
Removed {new Date(member.removedAt).toLocaleDateString()} Removed {new Date(member.removedAt).toLocaleDateString()}
</span> </span>
) : ( ) : (
<> <>
{onRestartMember &&
!isLeadMember(member) &&
(isTeamAlive || isTeamProvisioning) &&
runtimeEntry?.restartable !== false && (
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={restarting}
onClick={async () => {
setRestartError(null);
setRestarting(true);
try {
await onRestartMember(member.name);
} catch (error) {
setRestartError(
error instanceof Error ? error.message : 'Failed to restart member'
);
} finally {
setRestarting(false);
}
}}
>
{restarting ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RotateCcw size={14} />
)}
Restart
</Button>
)}
<Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}> <Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}>
<MessageSquare size={14} /> <MessageSquare size={14} />
Send Message Send Message

View file

@ -24,6 +24,7 @@ import type {
interface MemberDetailHeaderProps { interface MemberDetailHeaderProps {
member: ResolvedTeamMember; member: ResolvedTeamMember;
runtimeSummary?: string;
isTeamAlive?: boolean; isTeamAlive?: boolean;
isTeamProvisioning?: boolean; isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState; leadActivity?: LeadActivityState;
@ -38,6 +39,7 @@ interface MemberDetailHeaderProps {
export const MemberDetailHeader = ({ export const MemberDetailHeader = ({
member, member,
runtimeSummary,
isTeamAlive, isTeamAlive,
isTeamProvisioning, isTeamProvisioning,
leadActivity, leadActivity,
@ -139,6 +141,9 @@ export const MemberDetailHeader = ({
{/* NOTE: lead context token display disabled — usage formula is inaccurate */} {/* NOTE: lead context token display disabled — usage formula is inaccurate */}
</> </>
)} )}
{!editing && runtimeSummary ? (
<div className="mt-1 text-xs text-[var(--color-text-muted)]">{runtimeSummary}</div>
) : null}
</div> </div>
</DialogDescription> </DialogDescription>
</div> </div>

View file

@ -10,6 +10,7 @@ import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { import type {
LeadActivityState, LeadActivityState,
TeamAgentRuntimeEntry,
MemberSpawnStatusEntry, MemberSpawnStatusEntry,
ResolvedTeamMember, ResolvedTeamMember,
TeamTaskWithKanban, TeamTaskWithKanban,
@ -21,6 +22,7 @@ interface MemberListProps {
taskMap?: Map<string, TeamTaskWithKanban>; taskMap?: Map<string, TeamTaskWithKanban>;
pendingRepliesByMember?: Record<string, number>; pendingRepliesByMember?: Record<string, number>;
memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>; memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>;
memberRuntimeEntries?: Map<string, TeamAgentRuntimeEntry>;
isLaunchSettling?: boolean; isLaunchSettling?: boolean;
isTeamAlive?: boolean; isTeamAlive?: boolean;
isTeamProvisioning?: boolean; isTeamProvisioning?: boolean;
@ -169,6 +171,30 @@ function areLaunchParamsEquivalent(
); );
} }
function areMemberRuntimeEntriesEquivalent(
left: Map<string, TeamAgentRuntimeEntry> | undefined,
right: Map<string, TeamAgentRuntimeEntry> | 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( function areMemberListPropsEqual(
prev: Readonly<MemberListProps>, prev: Readonly<MemberListProps>,
next: Readonly<MemberListProps> next: Readonly<MemberListProps>
@ -179,6 +205,7 @@ function areMemberListPropsEqual(
areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) && areMemberTaskMapsEquivalent(prev.taskMap, next.taskMap) &&
arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) && arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) &&
areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) && areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) &&
areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) &&
prev.isLaunchSettling === next.isLaunchSettling && prev.isLaunchSettling === next.isLaunchSettling &&
prev.isTeamAlive === next.isTeamAlive && prev.isTeamAlive === next.isTeamAlive &&
prev.isTeamProvisioning === next.isTeamProvisioning && prev.isTeamProvisioning === next.isTeamProvisioning &&
@ -193,6 +220,7 @@ export const MemberList = memo(function MemberList({
taskMap, taskMap,
pendingRepliesByMember, pendingRepliesByMember,
memberSpawnStatuses, memberSpawnStatuses,
memberRuntimeEntries,
isLaunchSettling, isLaunchSettling,
isTeamAlive, isTeamAlive,
isTeamProvisioning, isTeamProvisioning,
@ -240,9 +268,10 @@ export const MemberList = memo(function MemberList({
const buildRuntimeSummary = useCallback( const buildRuntimeSummary = useCallback(
( (
member: ResolvedTeamMember, member: ResolvedTeamMember,
spawnEntry: MemberSpawnStatusEntry | undefined spawnEntry: MemberSpawnStatusEntry | undefined,
runtimeEntry: TeamAgentRuntimeEntry | undefined
): string | undefined => { ): string | undefined => {
return resolveMemberRuntimeSummary(member, launchParams, spawnEntry); return resolveMemberRuntimeSummary(member, launchParams, spawnEntry, runtimeEntry);
}, },
[launchParams] [launchParams]
); );
@ -275,6 +304,7 @@ export const MemberList = memo(function MemberList({
reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null; reviewCandidate && reviewCandidate.id !== member.currentTaskId ? reviewCandidate : null;
const awaitingReply = isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name]); const awaitingReply = isTeamAlive !== false && Boolean(pendingRepliesByMember?.[member.name]);
const spawnEntry = memberSpawnStatuses?.get(member.name); const spawnEntry = memberSpawnStatuses?.get(member.name);
const runtimeEntry = memberRuntimeEntries?.get(member.name);
return ( return (
<MemberCard <MemberCard
key={member.name} key={member.name}
@ -288,7 +318,11 @@ export const MemberList = memo(function MemberList({
reviewTask={isRemoved ? null : reviewTask} reviewTask={isRemoved ? null : reviewTask}
isAwaitingReply={isRemoved ? false : awaitingReply} isAwaitingReply={isRemoved ? false : awaitingReply}
isRemoved={isRemoved} isRemoved={isRemoved}
runtimeSummary={buildRuntimeSummary(member, isRemoved ? undefined : spawnEntry)} runtimeSummary={buildRuntimeSummary(
member,
isRemoved ? undefined : spawnEntry,
isRemoved ? undefined : runtimeEntry
)}
spawnStatus={isRemoved ? undefined : spawnEntry?.status} spawnStatus={isRemoved ? undefined : spawnEntry?.status}
spawnError={isRemoved ? undefined : spawnEntry?.error} spawnError={isRemoved ? undefined : spawnEntry?.error}
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource} spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}

View file

@ -35,6 +35,8 @@ import type {
KanbanColumnId, KanbanColumnId,
LeadActivityState, LeadActivityState,
LeadContextUsage, LeadContextUsage,
TeamAgentRuntimeEntry,
TeamAgentRuntimeSnapshot,
MemberSpawnStatusEntry, MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot, MemberSpawnStatusesSnapshot,
PersistedTeamLaunchSummary, PersistedTeamLaunchSummary,
@ -428,6 +430,47 @@ function maybeLogMemberSpawnUiEqualSuppressed(
); );
} }
function areTeamAgentRuntimeEntriesEqual(
left: TeamAgentRuntimeEntry | undefined,
right: TeamAgentRuntimeEntry | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
return (
left.memberName === right.memberName &&
left.alive === right.alive &&
left.restartable === right.restartable &&
left.backendType === right.backendType &&
left.pid === right.pid &&
left.runtimeModel === right.runtimeModel &&
left.rssBytes === right.rssBytes
);
}
function areTeamAgentRuntimeSnapshotsEqual(
left: TeamAgentRuntimeSnapshot | undefined,
right: TeamAgentRuntimeSnapshot
): boolean {
if (!left) return false;
if (left.teamName !== right.teamName || left.runId !== right.runId) {
return false;
}
const leftKeys = Object.keys(left.members);
const rightKeys = Object.keys(right.members);
if (leftKeys.length !== rightKeys.length) {
return false;
}
for (const key of leftKeys) {
if (!(key in right.members)) {
return false;
}
if (!areTeamAgentRuntimeEntriesEqual(left.members[key], right.members[key])) {
return false;
}
}
return true;
}
function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number { function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): number {
const aTime = Date.parse(a.timestamp); const aTime = Date.parse(a.timestamp);
const bTime = Date.parse(b.timestamp); const bTime = Date.parse(b.timestamp);
@ -1169,7 +1212,9 @@ export interface TeamSlice {
/** Per-team per-member spawn statuses during team provisioning/launch. */ /** Per-team per-member spawn statuses during team provisioning/launch. */
memberSpawnStatusesByTeam: Record<string, Record<string, MemberSpawnStatusEntry>>; memberSpawnStatusesByTeam: Record<string, Record<string, MemberSpawnStatusEntry>>;
memberSpawnSnapshotsByTeam: Record<string, MemberSpawnStatusesSnapshot>; memberSpawnSnapshotsByTeam: Record<string, MemberSpawnStatusesSnapshot>;
teamAgentRuntimeByTeam: Record<string, TeamAgentRuntimeSnapshot>;
fetchMemberSpawnStatuses: (teamName: string) => Promise<void>; fetchMemberSpawnStatuses: (teamName: string) => Promise<void>;
fetchTeamAgentRuntime: (teamName: string) => Promise<void>;
provisioningErrorByTeam: Record<string, string | null>; provisioningErrorByTeam: Record<string, string | null>;
clearProvisioningError: (teamName?: string) => void; clearProvisioningError: (teamName?: string) => void;
/** Per-team launch parameters (model, effort, extended context) — persisted in localStorage. */ /** Per-team launch parameters (model, effort, extended context) — persisted in localStorage. */
@ -1255,6 +1300,7 @@ export interface TeamSlice {
request: AddTaskCommentRequest request: AddTaskCommentRequest
) => Promise<TaskComment>; ) => Promise<TaskComment>;
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>; addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
restartMember: (teamName: string, memberName: string) => Promise<void>;
removeMember: (teamName: string, memberName: string) => Promise<void>; removeMember: (teamName: string, memberName: string) => Promise<void>;
updateMemberRole: ( updateMemberRole: (
teamName: string, teamName: string,
@ -1482,6 +1528,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
toolHistoryByTeam: {}, toolHistoryByTeam: {},
memberSpawnStatusesByTeam: {}, memberSpawnStatusesByTeam: {},
memberSpawnSnapshotsByTeam: {}, memberSpawnSnapshotsByTeam: {},
teamAgentRuntimeByTeam: {},
provisioningErrorByTeam: {}, provisioningErrorByTeam: {},
clearProvisioningError: (teamName?: string) => clearProvisioningError: (teamName?: string) =>
set((state) => { set((state) => {
@ -1591,6 +1638,36 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
// ignore — spawn statuses are best-effort // 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, kanbanFilterQuery: null,
globalTaskDetail: null, globalTaskDetail: null,
pendingMemberProfile: null, pendingMemberProfile: null,
@ -2875,6 +2952,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
await get().refreshTeamData(teamName); 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) => { removeMember: async (teamName: string, memberName: string) => {
await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName)); await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName));
await get().refreshTeamData(teamName); await get().refreshTeamData(teamName);
@ -2918,9 +3003,15 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const nextCache = state.teamDataCacheByName[teamName] const nextCache = state.teamDataCacheByName[teamName]
? { ...state.teamDataCacheByName } ? { ...state.teamDataCacheByName }
: null; : null;
const nextRuntime = state.teamAgentRuntimeByTeam[teamName]
? { ...state.teamAgentRuntimeByTeam }
: null;
if (nextCache) { if (nextCache) {
delete nextCache[teamName]; delete nextCache[teamName];
} }
if (nextRuntime) {
delete nextRuntime[teamName];
}
if (state.selectedTeamName === teamName) { if (state.selectedTeamName === teamName) {
return { return {
selectedTeamName: null, selectedTeamName: null,
@ -2928,9 +3019,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
selectedTeamLoading: false, selectedTeamLoading: false,
selectedTeamError: null, selectedTeamError: null,
...(nextCache ? { teamDataCacheByName: nextCache } : {}), ...(nextCache ? { teamDataCacheByName: nextCache } : {}),
...(nextRuntime ? { teamAgentRuntimeByTeam: nextRuntime } : {}),
}; };
} }
return nextCache ? { teamDataCacheByName: nextCache } : {}; return {
...(nextCache ? { teamDataCacheByName: nextCache } : {}),
...(nextRuntime ? { teamAgentRuntimeByTeam: nextRuntime } : {}),
};
}); });
await get().fetchTeams(); await get().fetchTeams();
await get().fetchAllTasks(); await get().fetchAllTasks();
@ -2939,14 +3034,23 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
restoreTeam: async (teamName: string) => { restoreTeam: async (teamName: string) => {
await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName)); await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName));
set((state) => { set((state) => {
if (!state.teamDataCacheByName[teamName]) { const hasCache = Boolean(state.teamDataCacheByName[teamName]);
const hasRuntime = Boolean(state.teamAgentRuntimeByTeam[teamName]);
if (!hasCache && !hasRuntime) {
return {}; return {};
} }
const nextCache = { ...state.teamDataCacheByName }; const nextState: Partial<TeamSlice> = {};
delete nextCache[teamName]; if (hasCache) {
return { const nextCache = { ...state.teamDataCacheByName };
teamDataCacheByName: nextCache, 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().fetchTeams();
await get().fetchAllTasks(); await get().fetchAllTasks();
@ -2956,17 +3060,21 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName)); await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName));
const state = get(); const state = get();
const nextCache = { ...state.teamDataCacheByName }; const nextCache = { ...state.teamDataCacheByName };
const nextRuntime = { ...state.teamAgentRuntimeByTeam };
delete nextCache[teamName]; delete nextCache[teamName];
delete nextRuntime[teamName];
if (state.selectedTeamName === teamName) { if (state.selectedTeamName === teamName) {
set({ set({
selectedTeamName: null, selectedTeamName: null,
selectedTeamData: null, selectedTeamData: null,
selectedTeamError: null, selectedTeamError: null,
teamDataCacheByName: nextCache, teamDataCacheByName: nextCache,
teamAgentRuntimeByTeam: nextRuntime,
}); });
} else if (state.teamDataCacheByName[teamName]) { } else if (state.teamDataCacheByName[teamName] || state.teamAgentRuntimeByTeam[teamName]) {
set({ set({
teamDataCacheByName: nextCache, teamDataCacheByName: nextCache,
teamAgentRuntimeByTeam: nextRuntime,
}); });
} }
await get().fetchTeams(); await get().fetchTeams();
@ -3000,6 +3108,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
delete nextSpawnStatuses[request.teamName]; delete nextSpawnStatuses[request.teamName];
const nextSpawnSnapshots = { ...state.memberSpawnSnapshotsByTeam }; const nextSpawnSnapshots = { ...state.memberSpawnSnapshotsByTeam };
delete nextSpawnSnapshots[request.teamName]; delete nextSpawnSnapshots[request.teamName];
const nextRuntime = { ...state.teamAgentRuntimeByTeam };
delete nextRuntime[request.teamName];
const nextActiveTools = { ...state.activeToolsByTeam }; const nextActiveTools = { ...state.activeToolsByTeam };
delete nextActiveTools[request.teamName]; delete nextActiveTools[request.teamName];
const nextFinishedVisible = { ...state.finishedVisibleByTeam }; const nextFinishedVisible = { ...state.finishedVisibleByTeam };
@ -3033,6 +3143,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
provisioningErrorByTeam: nextErrors, provisioningErrorByTeam: nextErrors,
memberSpawnStatusesByTeam: nextSpawnStatuses, memberSpawnStatusesByTeam: nextSpawnStatuses,
memberSpawnSnapshotsByTeam: nextSpawnSnapshots, memberSpawnSnapshotsByTeam: nextSpawnSnapshots,
teamAgentRuntimeByTeam: nextRuntime,
activeToolsByTeam: nextActiveTools, activeToolsByTeam: nextActiveTools,
finishedVisibleByTeam: nextFinishedVisible, finishedVisibleByTeam: nextFinishedVisible,
toolHistoryByTeam: nextToolHistory, toolHistoryByTeam: nextToolHistory,
@ -3198,6 +3309,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
delete nextSpawnStatuses[request.teamName]; delete nextSpawnStatuses[request.teamName];
const nextSpawnSnapshots = { ...state.memberSpawnSnapshotsByTeam }; const nextSpawnSnapshots = { ...state.memberSpawnSnapshotsByTeam };
delete nextSpawnSnapshots[request.teamName]; delete nextSpawnSnapshots[request.teamName];
const nextRuntime = { ...state.teamAgentRuntimeByTeam };
delete nextRuntime[request.teamName];
const nextActiveTools = { ...state.activeToolsByTeam }; const nextActiveTools = { ...state.activeToolsByTeam };
delete nextActiveTools[request.teamName]; delete nextActiveTools[request.teamName];
const nextFinishedVisible = { ...state.finishedVisibleByTeam }; const nextFinishedVisible = { ...state.finishedVisibleByTeam };
@ -3231,6 +3344,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
provisioningErrorByTeam: nextErrors, provisioningErrorByTeam: nextErrors,
memberSpawnStatusesByTeam: nextSpawnStatuses, memberSpawnStatusesByTeam: nextSpawnStatuses,
memberSpawnSnapshotsByTeam: nextSpawnSnapshots, memberSpawnSnapshotsByTeam: nextSpawnSnapshots,
teamAgentRuntimeByTeam: nextRuntime,
activeToolsByTeam: nextActiveTools, activeToolsByTeam: nextActiveTools,
finishedVisibleByTeam: nextFinishedVisible, finishedVisibleByTeam: nextFinishedVisible,
toolHistoryByTeam: nextToolHistory, toolHistoryByTeam: nextToolHistory,
@ -3392,9 +3506,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam }; const nextSpawnStatuses = { ...state.memberSpawnStatusesByTeam };
const nextSpawnSnapshots = { ...state.memberSpawnSnapshotsByTeam }; const nextSpawnSnapshots = { ...state.memberSpawnSnapshotsByTeam };
const nextRuntime = { ...state.teamAgentRuntimeByTeam };
if (isCanonicalRun) { if (isCanonicalRun) {
delete nextSpawnStatuses[existing.teamName]; delete nextSpawnStatuses[existing.teamName];
delete nextSpawnSnapshots[existing.teamName]; delete nextSpawnSnapshots[existing.teamName];
delete nextRuntime[existing.teamName];
} }
const nextActiveTools = { ...state.activeToolsByTeam }; const nextActiveTools = { ...state.activeToolsByTeam };
const nextFinishedVisible = { ...state.finishedVisibleByTeam }; const nextFinishedVisible = { ...state.finishedVisibleByTeam };
@ -3411,6 +3527,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam, currentRuntimeRunIdByTeam: nextRuntimeRunIdByTeam,
memberSpawnStatusesByTeam: nextSpawnStatuses, memberSpawnStatusesByTeam: nextSpawnStatuses,
memberSpawnSnapshotsByTeam: nextSpawnSnapshots, memberSpawnSnapshotsByTeam: nextSpawnSnapshots,
teamAgentRuntimeByTeam: nextRuntime,
activeToolsByTeam: nextActiveTools, activeToolsByTeam: nextActiveTools,
finishedVisibleByTeam: nextFinishedVisible, finishedVisibleByTeam: nextFinishedVisible,
toolHistoryByTeam: nextToolHistory, toolHistoryByTeam: nextToolHistory,
@ -3540,11 +3657,16 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
set((prev) => { set((prev) => {
const next = { ...prev.memberSpawnStatusesByTeam }; const next = { ...prev.memberSpawnStatusesByTeam };
const nextSnapshots = { ...prev.memberSpawnSnapshotsByTeam }; const nextSnapshots = { ...prev.memberSpawnSnapshotsByTeam };
const nextRuntime = { ...prev.teamAgentRuntimeByTeam };
const currentStatuses = next[progress.teamName]; const currentStatuses = next[progress.teamName];
if (!currentStatuses) { if (!currentStatuses) {
if (progress.state !== 'ready') {
delete nextRuntime[progress.teamName];
}
return { return {
memberSpawnStatusesByTeam: next, memberSpawnStatusesByTeam: next,
memberSpawnSnapshotsByTeam: nextSnapshots, memberSpawnSnapshotsByTeam: nextSnapshots,
teamAgentRuntimeByTeam: nextRuntime,
}; };
} }
if (progress.state === 'ready') { if (progress.state === 'ready') {
@ -3552,6 +3674,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return { return {
memberSpawnStatusesByTeam: next, memberSpawnStatusesByTeam: next,
memberSpawnSnapshotsByTeam: nextSnapshots, memberSpawnSnapshotsByTeam: nextSnapshots,
teamAgentRuntimeByTeam: nextRuntime,
}; };
} }
const retainedStatuses = Object.fromEntries( const retainedStatuses = Object.fromEntries(
@ -3563,9 +3686,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
delete next[progress.teamName]; delete next[progress.teamName];
delete nextSnapshots[progress.teamName]; delete nextSnapshots[progress.teamName];
} }
delete nextRuntime[progress.teamName];
return { return {
memberSpawnStatusesByTeam: next, memberSpawnStatusesByTeam: next,
memberSpawnSnapshotsByTeam: nextSnapshots, memberSpawnSnapshotsByTeam: nextSnapshots,
teamAgentRuntimeByTeam: nextRuntime,
}; };
}); });
} }

View file

@ -1,8 +1,14 @@
import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector'; import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector';
import { formatBytes } from '@renderer/utils/formatters';
import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider'; import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider';
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; 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 { function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): boolean {
if (!spawnEntry) { if (!spawnEntry) {
@ -20,22 +26,27 @@ function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined):
export function resolveMemberRuntimeSummary( export function resolveMemberRuntimeSummary(
member: ResolvedTeamMember, member: ResolvedTeamMember,
launchParams: TeamLaunchParams | undefined, launchParams: TeamLaunchParams | undefined,
spawnEntry: MemberSpawnStatusEntry | undefined spawnEntry: MemberSpawnStatusEntry | undefined,
runtimeEntry?: TeamAgentRuntimeEntry
): string | undefined { ): string | undefined {
const configuredProvider: TeamProviderId = const configuredProvider: TeamProviderId =
member.providerId ?? launchParams?.providerId ?? 'anthropic'; member.providerId ?? launchParams?.providerId ?? 'anthropic';
const configuredModel = member.model?.trim() || launchParams?.model?.trim() || ''; const configuredModel = member.model?.trim() || launchParams?.model?.trim() || '';
const configuredEffort = member.effort ?? launchParams?.effort; 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)) { if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) {
const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider; const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider;
return formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort); return `${formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort)}${memorySuffix}`;
} }
if (isMemberLaunchPending(spawnEntry)) { if (isMemberLaunchPending(spawnEntry)) {
return undefined; return undefined;
} }
return formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort); return `${formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort)}${memorySuffix}`;
} }

View file

@ -55,6 +55,7 @@ import type {
LeadContextUsageSnapshot, LeadContextUsageSnapshot,
MemberFullStats, MemberFullStats,
MemberLogSummary, MemberLogSummary,
TeamAgentRuntimeSnapshot,
MemberSpawnStatusesSnapshot, MemberSpawnStatusesSnapshot,
MessagesPage, MessagesPage,
ProjectBranchChangeEvent, ProjectBranchChangeEvent,
@ -532,6 +533,8 @@ export interface TeamsAPI {
getLeadActivity: (teamName: string) => Promise<LeadActivitySnapshot>; getLeadActivity: (teamName: string) => Promise<LeadActivitySnapshot>;
getLeadContext: (teamName: string) => Promise<LeadContextUsageSnapshot>; getLeadContext: (teamName: string) => Promise<LeadContextUsageSnapshot>;
getMemberSpawnStatuses: (teamName: string) => Promise<MemberSpawnStatusesSnapshot>; getMemberSpawnStatuses: (teamName: string) => Promise<MemberSpawnStatusesSnapshot>;
getTeamAgentRuntime: (teamName: string) => Promise<TeamAgentRuntimeSnapshot>;
restartMember: (teamName: string, memberName: string) => Promise<void>;
softDeleteTask: (teamName: string, taskId: string) => Promise<void>; softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
restoreTask: (teamName: string, taskId: string) => Promise<void>; restoreTask: (teamName: string, taskId: string) => Promise<void>;
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>; getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;

View file

@ -870,6 +870,26 @@ export interface MemberSpawnStatusesSnapshot {
export type MemberSpawnLivenessSource = 'heartbeat' | 'process'; 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<string, TeamAgentRuntimeEntry>;
}
export interface TeamChangeEvent { export interface TeamChangeEvent {
type: type:
| 'config' | 'config'

View file

@ -41,6 +41,7 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
import { import {
buildAddMemberSpawnMessage, buildAddMemberSpawnMessage,
buildRestartMemberSpawnMessage,
TeamProvisioningService, TeamProvisioningService,
} from '@main/services/team/TeamProvisioningService'; } from '@main/services/team/TeamProvisioningService';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
@ -279,6 +280,21 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
await svc.cancelProvisioning(runId); 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 () => { it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => {
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude'); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
const { child } = createFakeChild(); const { child } = createFakeChild();

View file

@ -13,11 +13,13 @@ const hoisted = vi.hoisted(() => ({
createTeam: vi.fn(), createTeam: vi.fn(),
getProvisioningStatus: vi.fn(), getProvisioningStatus: vi.fn(),
getMemberSpawnStatuses: vi.fn(), getMemberSpawnStatuses: vi.fn(),
getTeamAgentRuntime: vi.fn(),
cancelProvisioning: vi.fn(), cancelProvisioning: vi.fn(),
deleteTeam: vi.fn(), deleteTeam: vi.fn(),
restoreTeam: vi.fn(), restoreTeam: vi.fn(),
permanentlyDeleteTeam: vi.fn(), permanentlyDeleteTeam: vi.fn(),
sendMessage: vi.fn(), sendMessage: vi.fn(),
restartMember: vi.fn(),
requestReview: vi.fn(), requestReview: vi.fn(),
updateKanban: vi.fn(), updateKanban: vi.fn(),
invalidateTaskChangeSummaries: vi.fn(), invalidateTaskChangeSummaries: vi.fn(),
@ -32,11 +34,13 @@ vi.mock('@renderer/api', () => ({
createTeam: hoisted.createTeam, createTeam: hoisted.createTeam,
getProvisioningStatus: hoisted.getProvisioningStatus, getProvisioningStatus: hoisted.getProvisioningStatus,
getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses, getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses,
getTeamAgentRuntime: hoisted.getTeamAgentRuntime,
cancelProvisioning: hoisted.cancelProvisioning, cancelProvisioning: hoisted.cancelProvisioning,
deleteTeam: hoisted.deleteTeam, deleteTeam: hoisted.deleteTeam,
restoreTeam: hoisted.restoreTeam, restoreTeam: hoisted.restoreTeam,
permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam, permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam,
sendMessage: hoisted.sendMessage, sendMessage: hoisted.sendMessage,
restartMember: hoisted.restartMember,
requestReview: hoisted.requestReview, requestReview: hoisted.requestReview,
updateKanban: hoisted.updateKanban, updateKanban: hoisted.updateKanban,
onProvisioningProgress: hoisted.onProvisioningProgress, onProvisioningProgress: hoisted.onProvisioningProgress,
@ -125,6 +129,27 @@ function createMemberSpawnSnapshot(overrides: Record<string, unknown> = {}) {
}; };
} }
function createRuntimeSnapshot(overrides: Record<string, unknown> = {}) {
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', () => { describe('teamSlice actions', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -153,10 +178,12 @@ describe('teamSlice actions', () => {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });
hoisted.getMemberSpawnStatuses.mockResolvedValue({ statuses: {}, runId: null }); hoisted.getMemberSpawnStatuses.mockResolvedValue({ statuses: {}, runId: null });
hoisted.getTeamAgentRuntime.mockResolvedValue(createRuntimeSnapshot({ runId: null, members: {} }));
hoisted.cancelProvisioning.mockResolvedValue(undefined); hoisted.cancelProvisioning.mockResolvedValue(undefined);
hoisted.deleteTeam.mockResolvedValue(undefined); hoisted.deleteTeam.mockResolvedValue(undefined);
hoisted.restoreTeam.mockResolvedValue(undefined); hoisted.restoreTeam.mockResolvedValue(undefined);
hoisted.permanentlyDeleteTeam.mockResolvedValue(undefined); hoisted.permanentlyDeleteTeam.mockResolvedValue(undefined);
hoisted.restartMember.mockResolvedValue(undefined);
}); });
it('maps inbox verify failure to user-friendly text', async () => { it('maps inbox verify failure to user-friendly text', async () => {
@ -632,6 +659,64 @@ describe('teamSlice actions', () => {
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined(); 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', () => { describe('refreshTeamData provisioning safety', () => {
it('does not set fatal error on TEAM_PROVISIONING', async () => { it('does not set fatal error on TEAM_PROVISIONING', async () => {
const store = createSliceStore(); const store = createSliceStore();

View file

@ -63,4 +63,21 @@ describe('resolveMemberRuntimeSummary', () => {
expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBe('5.4 Mini · Medium'); 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'
);
});
}); });