From 2959a3d0749d9b46ba634f45eb5abddb0b202224 Mon Sep 17 00:00:00 2001 From: iliya Date: Sun, 5 Apr 2026 12:08:13 +0300 Subject: [PATCH] Refine launch status and Gemini staging flow --- .../services/team/TeamProvisioningService.ts | 371 ++++++++++++++++-- .../components/team/TeamDetailView.tsx | 6 + .../team/TeamProvisioningBanner.tsx | 23 +- .../components/team/members/MemberCard.tsx | 8 + .../components/team/members/MemberList.tsx | 13 +- src/renderer/utils/memberHelpers.ts | 18 +- src/shared/types/team.ts | 28 +- 7 files changed, 397 insertions(+), 70 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index d69f6a33..bffa2cfb 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -106,6 +106,7 @@ import type { CrossTeamSendResult, InboxMessage, LeadContextUsage, + MemberLaunchState, MemberSpawnStatus, MemberSpawnLivenessSource, MemberSpawnStatusEntry, @@ -591,8 +592,16 @@ interface ProvisioningRun { string, { status: MemberSpawnStatus; + launchState: MemberLaunchState; error?: string; + hardFailureReason?: string; livenessSource?: MemberSpawnLivenessSource; + agentToolAccepted?: boolean; + runtimeAlive?: boolean; + bootstrapConfirmed?: boolean; + hardFailure?: boolean; + firstSpawnAcceptedAt?: string; + lastHeartbeatAt?: string; updatedAt: string; } >; @@ -620,10 +629,44 @@ interface PromptSizeSummary { lines: number; } +const MEMBER_LAUNCH_GRACE_MS = 90_000; + function nowIso(): string { return new Date().toISOString(); } +function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry { + const updatedAt = nowIso(); + return { + status: 'offline', + launchState: 'starting', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + updatedAt, + }; +} + +function deriveMemberLaunchState(entry: { + status: MemberSpawnStatus; + agentToolAccepted?: boolean; + runtimeAlive?: boolean; + bootstrapConfirmed?: boolean; + hardFailure?: boolean; +}): MemberLaunchState { + if (entry.hardFailure || entry.status === 'error') { + return 'failed_to_start'; + } + if (entry.bootstrapConfirmed) { + return 'confirmed_alive'; + } + if (entry.runtimeAlive || entry.agentToolAccepted || entry.status === 'waiting') { + return 'runtime_pending_bootstrap'; + } + return 'starting'; +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -1720,15 +1763,17 @@ function buildGeminiPostLaunchHydrationPrompt( .map((member) => { const status = run.memberSpawnStatuses.get(member.name); const label = - status?.status === 'error' - ? `failed to start${status.error ? ` — ${status.error}` : ''}` - : status?.status === 'online' + status?.launchState === 'failed_to_start' + ? `failed to start${status.hardFailureReason ? ` — ${status.hardFailureReason}` : status.error ? ` — ${status.error}` : ''}` + : status?.launchState === 'confirmed_alive' ? 'bootstrap confirmed' - : status?.status === 'waiting' + : status?.runtimeAlive ? 'runtime started, bootstrap pending' - : status?.status === 'spawning' - ? 'spawn in progress' - : 'runtime state unclear'; + : status?.launchState === 'runtime_pending_bootstrap' + ? 'spawn accepted, bootstrap pending' + : status?.status === 'spawning' + ? 'spawn in progress' + : 'runtime state unclear'; return `- @${member.name}: ${label}`; }) .join('\n')}\n` @@ -2580,6 +2625,44 @@ export class TeamProvisioningService { } } + private async refreshMemberSpawnStatusesFromLeadInbox(run: ProvisioningRun): Promise { + const leadName = this.getRunLeadName(run); + let leadInboxMessages: Awaited> = []; + try { + leadInboxMessages = await this.inboxReader.getMessagesFor(run.teamName, leadName); + } catch { + return; + } + + const runStartedAtMs = Date.parse(run.startedAt); + const teammateMessages = leadInboxMessages + .filter((message) => { + const from = typeof message.from === 'string' ? message.from.trim() : ''; + if (!from || from === leadName || from === 'user' || from === 'system') return false; + if (!run.expectedMembers.includes(from)) return false; + const messageTs = Date.parse(message.timestamp); + if ( + Number.isFinite(messageTs) && + Number.isFinite(runStartedAtMs) && + messageTs < runStartedAtMs + ) { + return false; + } + return typeof message.text === 'string' && message.text.trim().length > 0; + }) + .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + + for (const message of teammateMessages) { + const from = message.from.trim(); + const reason = extractBootstrapFailureReason(message.text); + if (reason) { + this.setMemberSpawnStatus(run, from, 'error', reason); + continue; + } + this.setMemberSpawnStatus(run, from, 'online', undefined, 'heartbeat'); + } + } + private persistSentMessage(teamName: string, message: InboxMessage): void { try { createController({ @@ -3008,20 +3091,65 @@ export class TeamProvisioningService { error?: string, livenessSource?: MemberSpawnLivenessSource ): void { - const prev = run.memberSpawnStatuses.get(memberName); + const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry(); + const updatedAt = nowIso(); + const next: MemberSpawnStatusEntry = { + ...prev, + status, + updatedAt, + }; + + if (status === 'spawning') { + next.launchState = 'starting'; + } else if (status === 'waiting') { + next.agentToolAccepted = true; + next.hardFailure = false; + next.error = undefined; + next.hardFailureReason = undefined; + next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt; + next.launchState = 'runtime_pending_bootstrap'; + } else if (status === 'online') { + next.agentToolAccepted = true; + next.runtimeAlive = true; + next.livenessSource = livenessSource; + next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt; + if (livenessSource === 'heartbeat') { + next.bootstrapConfirmed = true; + next.lastHeartbeatAt = updatedAt; + } + next.hardFailure = false; + next.error = undefined; + next.hardFailureReason = undefined; + next.launchState = deriveMemberLaunchState(next); + } else if (status === 'error') { + next.error = error; + next.hardFailure = true; + next.hardFailureReason = error; + next.launchState = 'failed_to_start'; + } else if (status === 'offline') { + Object.assign(next, createInitialMemberSpawnStatusEntry(), { updatedAt }); + } + + next.launchState = deriveMemberLaunchState(next); if ( - prev?.status === status && - prev?.error === error && - prev?.livenessSource === livenessSource + prev.status === next.status && + prev.launchState === next.launchState && + prev.error === next.error && + prev.hardFailureReason === next.hardFailureReason && + prev.livenessSource === next.livenessSource && + prev.agentToolAccepted === next.agentToolAccepted && + prev.runtimeAlive === next.runtimeAlive && + prev.bootstrapConfirmed === next.bootstrapConfirmed && + prev.hardFailure === next.hardFailure && + prev.firstSpawnAcceptedAt === next.firstSpawnAcceptedAt && + prev.lastHeartbeatAt === next.lastHeartbeatAt ) { return; } - run.memberSpawnStatuses.set(memberName, { - status, - error, - livenessSource, - updatedAt: nowIso(), - }); + + run.memberSpawnStatuses.set(memberName, next); + this.syncMemberLaunchGraceCheck(run, memberName, next); + if (status === 'spawning') { this.appendMemberBootstrapDiagnostic(run, memberName, 'Agent tool invoked'); } else if (status === 'waiting') { @@ -3074,14 +3202,96 @@ export class TeamProvisioningService { for (const [name, entry] of run.memberSpawnStatuses) { result[name] = { status: entry.status, + launchState: entry.launchState, error: entry.error, + hardFailureReason: entry.hardFailureReason, livenessSource: entry.livenessSource, + agentToolAccepted: entry.agentToolAccepted, + runtimeAlive: entry.runtimeAlive, + bootstrapConfirmed: entry.bootstrapConfirmed, + hardFailure: entry.hardFailure, + firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt, + lastHeartbeatAt: entry.lastHeartbeatAt, updatedAt: entry.updatedAt, }; } return { statuses: result, runId }; } + private getMemberLaunchGraceKey(run: ProvisioningRun, memberName: string): string { + return `member-launch-grace:${run.runId}:${memberName}`; + } + + private syncMemberLaunchGraceCheck( + run: ProvisioningRun, + memberName: string, + entry: MemberSpawnStatusEntry + ): void { + const key = this.getMemberLaunchGraceKey(run, memberName); + const existing = this.pendingTimeouts.get(key); + if (entry.launchState === 'failed_to_start' || entry.launchState === 'confirmed_alive') { + if (existing) { + clearTimeout(existing); + this.pendingTimeouts.delete(key); + } + return; + } + if (!entry.firstSpawnAcceptedAt) { + return; + } + const remainingMs = + Date.parse(entry.firstSpawnAcceptedAt) + MEMBER_LAUNCH_GRACE_MS - Date.now(); + if (remainingMs <= 0) { + if (existing) { + clearTimeout(existing); + this.pendingTimeouts.delete(key); + } + void this.reevaluateMemberLaunchStatus(run, memberName); + return; + } + if (existing) { + return; + } + const timer = setTimeout(() => { + this.pendingTimeouts.delete(key); + void this.reevaluateMemberLaunchStatus(run, memberName); + }, remainingMs); + timer.unref?.(); + this.pendingTimeouts.set(key, timer); + } + + private async reevaluateMemberLaunchStatus( + run: ProvisioningRun, + memberName: string + ): Promise { + const current = run.memberSpawnStatuses.get(memberName); + if (!current) return; + if ( + current.launchState === 'failed_to_start' || + current.launchState === 'confirmed_alive' || + !current.firstSpawnAcceptedAt + ) { + return; + } + await this.refreshMemberSpawnStatusesFromLeadInbox(run); + await this.auditMemberSpawnStatuses(run); + const refreshed = run.memberSpawnStatuses.get(memberName); + if (!refreshed) return; + if ( + refreshed.launchState === 'failed_to_start' || + refreshed.launchState === 'confirmed_alive' || + refreshed.runtimeAlive + ) { + return; + } + this.setMemberSpawnStatus( + run, + memberName, + 'error', + 'Teammate did not join within the launch grace window.' + ); + } + private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000; private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000; @@ -4063,7 +4273,7 @@ export class TeamProvisioningService { geminiPostLaunchHydrationSent: false, suppressGeminiPostLaunchHydrationOutput: false, memberSpawnStatuses: new Map( - request.members.map((m) => [m.name, { status: 'offline' as const, updatedAt: nowIso() }]) + request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()]) ), memberSpawnToolUseIds: new Map(), progress: { @@ -4545,7 +4755,7 @@ export class TeamProvisioningService { geminiPostLaunchHydrationSent: false, suppressGeminiPostLaunchHydrationOutput: false, memberSpawnStatuses: new Map( - expectedMembers.map((name) => [name, { status: 'offline' as const, updatedAt: nowIso() }]) + expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()]) ), memberSpawnToolUseIds: new Map(), progress: { @@ -5155,6 +5365,8 @@ export class TeamProvisioningService { return 0; } + await this.refreshMemberSpawnStatusesFromLeadInbox(run); + const unread = leadInboxMessages .filter((m): m is InboxMessage & { messageId: string } => { if (m.read) return false; @@ -5731,7 +5943,12 @@ export class TeamProvisioningService { // Flag any expected member not found in config.json (excluding the lead) for (const expected of run.expectedMembers) { const current = run.memberSpawnStatuses.get(expected); - if (current?.status === 'error' || current?.status === 'online') continue; + if ( + current?.launchState === 'failed_to_start' || + current?.launchState === 'confirmed_alive' + ) { + continue; + } const matchedRuntimeNames = [...registeredNames].filter((name) => { if (name === expected) return true; @@ -5739,30 +5956,50 @@ export class TeamProvisioningService { return parsed !== null && parsed.suffix >= 2 && parsed.base === expected; }); - // A teammate may intentionally stay silent after bootstrap. If Claude Code - // registered the runtime and the OS process is still alive, treat it as - // process-confirmed running. Keep this distinct from heartbeat-confirmed online. - if ( + const runtimeAlive = matchedRuntimeNames.length > 0 && matchedRuntimeNames.some((runtimeName) => this.hasLiveTeamAgentProcess(run.teamName, runtimeName) - ) - ) { + ); + + // A teammate may intentionally stay silent after bootstrap. If Claude Code + // registered the runtime and the OS process is still alive, treat it as + // process-confirmed running. Keep this distinct from heartbeat-confirmed online. + if (runtimeAlive) { this.setMemberSpawnStatus(run, expected, 'online', undefined, 'process'); continue; } - if (matchedRuntimeNames.length > 0) continue; + if (matchedRuntimeNames.length > 0) { + if (current?.agentToolAccepted) { + this.setMemberSpawnStatus(run, expected, 'waiting'); + } + continue; + } + + const acceptedAtMs = + current?.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + const graceExpired = + current?.agentToolAccepted === true && + Number.isFinite(acceptedAtMs) && + Date.now() - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS; + + if (current?.agentToolAccepted && !graceExpired) { + this.setMemberSpawnStatus(run, expected, 'waiting'); + continue; + } logger.warn( `[${run.teamName}] Member "${expected}" not found in config.json members after provisioning` ); - this.setMemberSpawnStatus( - run, - expected, - 'error', - 'Teammate not registered after provisioning — spawned incorrectly. Restart the team to fix.' - ); + if (graceExpired) { + this.setMemberSpawnStatus( + run, + expected, + 'error', + 'Teammate not registered after provisioning within the launch grace window.' + ); + } } } @@ -5802,11 +6039,43 @@ export class TeamProvisioningService { run: ProvisioningRun ): { name: string; error?: string; updatedAt: string }[] { return [...run.memberSpawnStatuses.entries()] - .filter(([, entry]) => entry.status === 'error') - .map(([name, entry]) => ({ name, error: entry.error, updatedAt: entry.updatedAt })) + .filter(([, entry]) => entry.launchState === 'failed_to_start') + .map(([name, entry]) => ({ + name, + error: entry.hardFailureReason ?? entry.error, + updatedAt: entry.updatedAt, + })) .sort((a, b) => a.name.localeCompare(b.name)); } + private getMemberLaunchSummary(run: ProvisioningRun): { + confirmedCount: number; + pendingCount: number; + failedCount: number; + runtimeAlivePendingCount: number; + } { + let confirmedCount = 0; + let pendingCount = 0; + let failedCount = 0; + let runtimeAlivePendingCount = 0; + for (const expected of run.expectedMembers) { + const entry = run.memberSpawnStatuses.get(expected) ?? createInitialMemberSpawnStatusEntry(); + if (entry.launchState === 'confirmed_alive') { + confirmedCount += 1; + continue; + } + if (entry.launchState === 'failed_to_start') { + failedCount += 1; + continue; + } + pendingCount += 1; + if (entry.runtimeAlive) { + runtimeAlivePendingCount += 1; + } + } + return { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount }; + } + private async persistPartialLaunchState(run: ProvisioningRun): Promise { if (!run.isLaunch || !run.expectedMembers || run.expectedMembers.length === 0) { if (run.isLaunch) { @@ -8140,18 +8409,24 @@ export class TeamProvisioningService { } // Audit: flag any expected member not registered in config.json after launch. + await this.refreshMemberSpawnStatusesFromLeadInbox(run); await this.auditMemberSpawnStatuses(run); await this.persistPartialLaunchState(run); const failedSpawnMembers = this.getFailedSpawnMembers(run); + const launchSummary = this.getMemberLaunchSummary(run); const hasSpawnFailures = failedSpawnMembers.length > 0; + const hasPendingBootstrap = + !hasSpawnFailures && launchSummary.pendingCount > 0 && run.expectedMembers.length > 0; const readyMessage = hasSpawnFailures ? `Launch completed with teammate errors — ${failedSpawnMembers .map((member) => member.name) .join(', ')} failed to start` - : 'Team launched — process alive and ready'; + : hasPendingBootstrap + ? `Launch completed — ${launchSummary.confirmedCount}/${run.expectedMembers.length} teammates confirmed alive, bootstrap still pending` + : 'Team launched — process alive and ready'; const progress = updateProgress(run, 'ready', readyMessage, { cliLogsTail: extractCliLogsFromRun(run), - messageSeverity: hasSpawnFailures ? 'warning' : undefined, + messageSeverity: hasSpawnFailures || hasPendingBootstrap ? 'warning' : undefined, }); run.onProgress(progress); this.provisioningRunByTeam.delete(run.teamName); @@ -8171,7 +8446,7 @@ export class TeamProvisioningService { detail: 'lead-session-sync', }); - if (!hasSpawnFailures) { + if (!hasSpawnFailures && !hasPendingBootstrap) { // Fire "Team Launched" notification only for clean launches. void this.fireTeamLaunchedNotification(run); } @@ -8293,10 +8568,14 @@ export class TeamProvisioningService { await this.teamMetaStore.deleteMeta(run.teamName).catch(() => {}); // Audit: flag any expected member not registered in config.json after provisioning. + await this.refreshMemberSpawnStatusesFromLeadInbox(run); await this.auditMemberSpawnStatuses(run); await this.clearPartialLaunchState(run.teamName); const failedSpawnMembers = this.getFailedSpawnMembers(run); + const launchSummary = this.getMemberLaunchSummary(run); const hasSpawnFailures = failedSpawnMembers.length > 0; + const hasPendingBootstrap = + !hasSpawnFailures && launchSummary.pendingCount > 0 && run.expectedMembers.length > 0; const progress = updateProgress( run, 'ready', @@ -8304,10 +8583,12 @@ export class TeamProvisioningService { ? `Provisioning completed with teammate errors — ${failedSpawnMembers .map((member) => member.name) .join(', ')} failed to start` - : 'Team provisioned — process alive and ready', + : hasPendingBootstrap + ? `Team provisioned — ${launchSummary.confirmedCount}/${run.expectedMembers.length} teammates confirmed alive, bootstrap still pending` + : 'Team provisioned — process alive and ready', { cliLogsTail: extractCliLogsFromRun(run), - messageSeverity: hasSpawnFailures ? 'warning' : undefined, + messageSeverity: hasSpawnFailures || hasPendingBootstrap ? 'warning' : undefined, } ); run.onProgress(progress); @@ -8328,7 +8609,7 @@ export class TeamProvisioningService { detail: 'lead-session-sync', }); - if (!hasSpawnFailures) { + if (!hasSpawnFailures && !hasPendingBootstrap) { // Fire "Team Launched" notification only for clean launches. void this.fireTeamLaunchedNotification(run); } @@ -8702,6 +8983,14 @@ export class TeamProvisioningService { this.pendingTimeouts.delete(key); } } + for (const memberName of run.memberSpawnStatuses.keys()) { + const key = this.getMemberLaunchGraceKey(run, memberName); + const timer = this.pendingTimeouts.get(key); + if (timer) { + clearTimeout(timer); + this.pendingTimeouts.delete(key); + } + } run.activeCrossTeamReplyHints = []; run.pendingInboxRelayCandidates = []; for (const key of Array.from(this.memberInboxRelayInFlight.keys())) { diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 214af142..595ad1df 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -577,14 +577,20 @@ export const TeamDetailView = ({ string, { status: MemberSpawnStatusEntry['status']; + launchState: MemberSpawnStatusEntry['launchState']; error?: string; + updatedAt: string; + runtimeAlive?: boolean; livenessSource?: MemberSpawnStatusEntry['livenessSource']; } >(); for (const [name, entry] of Object.entries(memberSpawnStatuses)) { map.set(name, { status: entry.status, + launchState: entry.launchState, error: entry.error, + updatedAt: entry.updatedAt, + runtimeAlive: entry.runtimeAlive, livenessSource: entry.livenessSource, }); } diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index 67dab2b2..93aa9fb6 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -106,20 +106,23 @@ export const TeamProvisioningBanner = ({ const teammates = (teamMembers ?? []).filter((member) => !isLeadMember(member)); const failedSpawnEntries = Object.entries(memberSpawnStatuses ?? {}).filter( - ([, entry]) => entry.status === 'error' + ([, entry]) => entry.launchState === 'failed_to_start' ); const failedSpawnCount = failedSpawnEntries.length; const heartbeatConfirmedCount = teammates.filter((member) => { const entry = memberSpawnStatuses?.[member.name]; - return entry?.status === 'online' && entry.livenessSource === 'heartbeat'; + return entry?.launchState === 'confirmed_alive'; }).length; const processOnlyAliveCount = teammates.filter((member) => { const entry = memberSpawnStatuses?.[member.name]; - return entry?.status === 'online' && entry.livenessSource === 'process'; + return entry?.launchState === 'runtime_pending_bootstrap' && entry.runtimeAlive === true; }).length; - const awaitingHeartbeatCount = teammates.filter((member) => { + const pendingSpawnCount = teammates.filter((member) => { const entry = memberSpawnStatuses?.[member.name]; - return entry?.status === 'waiting'; + return ( + entry?.launchState === 'starting' || + (entry?.launchState === 'runtime_pending_bootstrap' && entry.runtimeAlive !== true) + ); }).length; const allTeammatesConfirmedAlive = teammates.length > 0 && failedSpawnCount === 0 && heartbeatConfirmedCount === teammates.length; @@ -132,8 +135,8 @@ export const TeamProvisioningBanner = ({ ? 'Team launched — lead online' : allTeammatesConfirmedAlive ? `Team launched — all ${teammates.length} teammates confirmed alive` - : processOnlyAliveCount > 0 || awaitingHeartbeatCount > 0 - ? `Team launched — ${heartbeatConfirmedCount}/${teammates.length} teammates confirmed alive${processOnlyAliveCount > 0 ? `, ${processOnlyAliveCount} runtime${processOnlyAliveCount === 1 ? '' : 's'} alive but bootstrap still pending` : ''}${awaitingHeartbeatCount > 0 ? `${processOnlyAliveCount > 0 ? ', ' : ', '}${awaitingHeartbeatCount} awaiting first heartbeat` : ''}` + : processOnlyAliveCount > 0 || pendingSpawnCount > 0 + ? `Team launched — ${heartbeatConfirmedCount}/${teammates.length} teammates confirmed alive${processOnlyAliveCount > 0 ? `, ${processOnlyAliveCount} runtime${processOnlyAliveCount === 1 ? '' : 's'} alive but bootstrap still pending` : ''}${pendingSpawnCount > 0 ? `${processOnlyAliveCount > 0 ? ', ' : ', '}${pendingSpawnCount} still starting` : ''}` : 'Team launched — teammate liveness is still being confirmed'; return ( @@ -151,7 +154,11 @@ export const TeamProvisioningBanner = ({ defaultLiveOutputOpen={false} onCancel={null} successMessage={readyMessage} - successMessageSeverity={failedSpawnCount > 0 ? 'warning' : 'success'} + successMessageSeverity={ + failedSpawnCount > 0 || processOnlyAliveCount > 0 || pendingSpawnCount > 0 + ? 'warning' + : 'success' + } onDismiss={() => setDismissed(true)} /> diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 853b68ca..8934da38 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -18,6 +18,7 @@ import { CurrentTaskIndicator } from './CurrentTaskIndicator'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { LeadActivityState, + MemberLaunchState, MemberSpawnLivenessSource, MemberSpawnStatus, ResolvedTeamMember, @@ -39,6 +40,8 @@ interface MemberCardProps { spawnStatus?: MemberSpawnStatus; spawnError?: string; spawnLivenessSource?: MemberSpawnLivenessSource; + spawnLaunchState?: MemberLaunchState; + spawnRuntimeAlive?: boolean; onOpenTask?: () => void; onOpenReviewTask?: () => void; onClick?: () => void; @@ -61,6 +64,8 @@ export const MemberCard = ({ spawnStatus, spawnError, spawnLivenessSource, + spawnLaunchState, + spawnRuntimeAlive, onOpenTask, onOpenReviewTask, onClick, @@ -75,6 +80,7 @@ export const MemberCard = ({ const dotClass = getSpawnAwareDotClass( member, spawnStatus, + spawnLaunchState, isTeamAlive, isTeamProvisioning, leadActivity @@ -82,7 +88,9 @@ export const MemberCard = ({ const presenceLabel = getSpawnAwarePresenceLabel( member, spawnStatus, + spawnLaunchState, spawnLivenessSource, + spawnRuntimeAlive, isTeamAlive, isTeamProvisioning, leadActivity diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 3ca82fd1..9fdbc247 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -15,24 +15,17 @@ import { MemberCard } from './MemberCard'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { LeadActivityState, - MemberSpawnLivenessSource, - MemberSpawnStatus, + MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban, } from '@shared/types'; -export interface MemberSpawnEntry { - status: MemberSpawnStatus; - error?: string; - livenessSource?: MemberSpawnLivenessSource; -} - interface MemberListProps { members: ResolvedTeamMember[]; memberTaskCounts?: Map; taskMap?: Map; pendingRepliesByMember?: Record; - memberSpawnStatuses?: Map; + memberSpawnStatuses?: Map; isTeamAlive?: boolean; isTeamProvisioning?: boolean; leadActivity?: LeadActivityState; @@ -137,6 +130,8 @@ export const MemberList = ({ spawnStatus={isRemoved ? undefined : spawnEntry?.status} spawnError={isRemoved ? undefined : spawnEntry?.error} spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource} + spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState} + spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive} onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined} onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask) : undefined} onClick={() => onMemberClick?.(member)} diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 1aa194ba..dd02fd0a 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -7,6 +7,7 @@ import { isLeadMember } from '@shared/utils/leadDetection'; import type { LeadActivityState, + MemberLaunchState, MemberSpawnLivenessSource, MemberSpawnStatus, MemberStatus, @@ -99,7 +100,7 @@ export const SPAWN_DOT_COLORS: Record = { export const SPAWN_PRESENCE_LABELS: Record = { offline: 'offline', - waiting: 'awaiting heartbeat', + waiting: 'bootstrap pending', spawning: 'starting', online: 'ready', error: 'spawn failed', @@ -112,13 +113,17 @@ export const SPAWN_PRESENCE_LABELS: Record = { export function getSpawnAwareDotClass( member: ResolvedTeamMember, spawnStatus: MemberSpawnStatus | undefined, + spawnLaunchState: MemberLaunchState | undefined, isTeamAlive?: boolean, isTeamProvisioning?: boolean, leadActivity?: LeadActivityState ): string { - if (spawnStatus === 'error') { + if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') { return SPAWN_DOT_COLORS.error; } + if (spawnLaunchState === 'runtime_pending_bootstrap' && spawnStatus === 'online') { + return SPAWN_DOT_COLORS.spawning; + } if (spawnStatus === 'waiting') { return SPAWN_DOT_COLORS.waiting; } @@ -140,22 +145,27 @@ export function getSpawnAwareDotClass( export function getSpawnAwarePresenceLabel( member: ResolvedTeamMember, spawnStatus: MemberSpawnStatus | undefined, + spawnLaunchState: MemberLaunchState | undefined, livenessSource: MemberSpawnLivenessSource | undefined, + runtimeAlive: boolean | undefined, isTeamAlive?: boolean, isTeamProvisioning?: boolean, leadActivity?: LeadActivityState ): string { - if (spawnStatus === 'error') { + if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') { return SPAWN_PRESENCE_LABELS.error; } if (spawnStatus === 'offline' && isTeamProvisioning) { return 'waiting for Agent'; } + if (spawnLaunchState === 'runtime_pending_bootstrap' && runtimeAlive) { + return 'bootstrap pending'; + } if (spawnStatus === 'waiting') { return SPAWN_PRESENCE_LABELS.waiting; } if (spawnStatus === 'online' && livenessSource === 'process') { - return 'running'; + return 'bootstrap pending'; } if (spawnStatus && isTeamProvisioning) { return SPAWN_PRESENCE_LABELS[spawnStatus]; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 2a8708f2..c3991cc4 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -440,15 +440,12 @@ export interface AddTaskCommentRequest { export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown'; -/** - * Spawn lifecycle status for a team member during team launch/reconnect. - * - offline: queued, Agent tool_use not sent yet - * - spawning: Agent tool_use sent, awaiting tool_result - * - waiting: teammate process accepted by runtime, awaiting first heartbeat/inbox signal - * - online: first heartbeat/inbox signal received - * - error: spawn failed (tool_result with error) - */ export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; +export type MemberLaunchState = + | 'starting' + | 'runtime_pending_bootstrap' + | 'confirmed_alive' + | 'failed_to_start'; export type KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved'; @@ -610,14 +607,29 @@ export interface ProjectBranchChangeEvent { /** Per-member spawn status entry, exposed to renderer via IPC. */ export interface MemberSpawnStatusEntry { status: MemberSpawnStatus; + launchState: MemberLaunchState; /** Error message when status === 'error'. */ error?: string; + /** Hard failure reason for failed_to_start. */ + hardFailureReason?: string; /** * Optional provenance for `online`. * - heartbeat: teammate sent a real inbox/native message after bootstrap * - process: runtime process is alive, but bootstrap/first reply is not yet confirmed */ livenessSource?: MemberSpawnLivenessSource; + /** Agent tool_result confirmed the spawn request was accepted by the runtime. */ + agentToolAccepted?: boolean; + /** Runtime process or registered teammate runtime is currently alive. */ + runtimeAlive?: boolean; + /** A real teammate heartbeat/bootstrap confirmation was observed. */ + bootstrapConfirmed?: boolean; + /** Hard failure observed from spawn/bootstrap/runtime evidence. */ + hardFailure?: boolean; + /** ISO timestamp of the first accepted teammate spawn for this member. */ + firstSpawnAcceptedAt?: string; + /** ISO timestamp of the latest confirmed heartbeat/bootstrap message. */ + lastHeartbeatAt?: string; /** ISO timestamp of the last status change. */ updatedAt: string; }