From 8cd3f04c20a05933a1426f24e788e02b026b7923 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 23 Apr 2026 01:05:54 +0300 Subject: [PATCH] fix(team): align permission-blocked launch state --- .../agent-graph/src/canvas/draw-agents.ts | 2 + packages/agent-graph/src/ports/types.ts | 1 + .../services/team/TeamLaunchStateEvaluator.ts | 44 ++++++++++++- .../services/team/TeamProvisioningService.ts | 24 ++++--- .../team/members/MemberDetailDialog.tsx | 3 +- .../components/team/provisioningSteps.ts | 5 +- src/renderer/utils/memberHelpers.ts | 17 +++++ src/renderer/utils/memberRuntimeSummary.ts | 1 + src/shared/types/team.ts | 4 ++ .../team/members/MemberCard.test.ts | 31 +++++++++ test/renderer/utils/memberHelpers.test.ts | 20 ++++++ .../teamProvisioningPresentation.test.ts | 64 +++++++++++++++++++ 12 files changed, 203 insertions(+), 13 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index ed8db002..0e844e9d 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -732,6 +732,8 @@ function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): stri return hexWithAlpha('#d4d4d8', 0.8); case 'spawning': return hexWithAlpha('#f59e0b', 0.9); + case 'permission_pending': + return hexWithAlpha('#f59e0b', 0.92); case 'runtime_pending': return hexWithAlpha('#67e8f9', 0.9); case 'settling': diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index af439ae4..fa7461bc 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -20,6 +20,7 @@ export type GraphNodeState = export type GraphLaunchVisualState = | 'waiting' | 'spawning' + | 'permission_pending' | 'runtime_pending' | 'settling' | 'error'; diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index 63fee42d..245a8c9c 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -36,11 +36,23 @@ type RuntimeMemberSpawnState = Pick< | 'runtimeAlive' | 'bootstrapConfirmed' | 'hardFailure' + | 'pendingPermissionRequestIds' | 'firstSpawnAcceptedAt' | 'lastHeartbeatAt' | 'updatedAt' >; +function normalizePendingPermissionRequestIds(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const normalized = value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter((item) => item.length > 0); + return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined; +} + function normalizeMemberName(name: string): string { return name.trim(); } @@ -48,15 +60,23 @@ function normalizeMemberName(name: string): string { function buildDiagnostics( member: Pick< PersistedTeamLaunchMemberState, - 'agentToolAccepted' | 'runtimeAlive' | 'bootstrapConfirmed' | 'hardFailureReason' | 'sources' + | 'agentToolAccepted' + | 'runtimeAlive' + | 'bootstrapConfirmed' + | 'hardFailureReason' + | 'sources' + | 'pendingPermissionRequestIds' > ): string[] { const diagnostics: string[] = []; if (member.agentToolAccepted) diagnostics.push('spawn accepted'); if (member.runtimeAlive) diagnostics.push('runtime alive'); if (member.bootstrapConfirmed) diagnostics.push('late heartbeat received'); - if (member.runtimeAlive && !member.bootstrapConfirmed) + if ((member.pendingPermissionRequestIds?.length ?? 0) > 0) { + diagnostics.push('waiting for permission approval'); + } else if (member.runtimeAlive && !member.bootstrapConfirmed) { diagnostics.push('waiting for teammate check-in'); + } if (member.hardFailureReason) diagnostics.push(`hard failure reason: ${member.hardFailureReason}`); if (member.sources?.duplicateRespawnBlocked) diagnostics.push('respawn blocked as duplicate'); @@ -133,7 +153,11 @@ export function hasMixedPersistedLaunchMetadata( function deriveMemberLaunchState( member: Pick< PersistedTeamLaunchMemberState, - 'hardFailure' | 'bootstrapConfirmed' | 'runtimeAlive' | 'agentToolAccepted' + | 'hardFailure' + | 'bootstrapConfirmed' + | 'runtimeAlive' + | 'agentToolAccepted' + | 'pendingPermissionRequestIds' > ): MemberLaunchState { if (member.hardFailure) { @@ -142,6 +166,9 @@ function deriveMemberLaunchState( if (member.bootstrapConfirmed) { return 'confirmed_alive'; } + if ((member.pendingPermissionRequestIds?.length ?? 0) > 0) { + return 'runtime_pending_permission'; + } if (member.runtimeAlive || member.agentToolAccepted) { return 'runtime_pending_bootstrap'; } @@ -297,6 +324,9 @@ function normalizePersistedMemberState( typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0 ? parsed.hardFailureReason.trim() : undefined, + pendingPermissionRequestIds: normalizePendingPermissionRequestIds( + parsed.pendingPermissionRequestIds + ), firstSpawnAcceptedAt: typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined, lastHeartbeatAt: @@ -315,6 +345,7 @@ function normalizePersistedMemberState( const launchState = parsed.launchState === 'starting' || parsed.launchState === 'runtime_pending_bootstrap' || + parsed.launchState === 'runtime_pending_permission' || parsed.launchState === 'confirmed_alive' || parsed.launchState === 'failed_to_start' ? parsed.launchState @@ -423,6 +454,9 @@ export function snapshotFromRuntimeMemberStatuses(params: { bootstrapConfirmed: runtime?.bootstrapConfirmed === true, hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start', hardFailureReason: runtime?.hardFailureReason ?? runtime?.error, + pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length + ? [...new Set(runtime.pendingPermissionRequestIds)] + : undefined, firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt, lastHeartbeatAt: runtime?.lastHeartbeatAt, lastRuntimeAliveAt: runtime?.runtimeAlive ? updatedAt : undefined, @@ -460,6 +494,9 @@ export function snapshotToMemberSpawnStatuses( } else if (entry.launchState === 'confirmed_alive') { status = 'online'; livenessSource = 'heartbeat'; + } else if (entry.launchState === 'runtime_pending_permission') { + status = entry.runtimeAlive ? 'online' : 'waiting'; + livenessSource = entry.runtimeAlive ? 'process' : undefined; } else if (entry.launchState === 'runtime_pending_bootstrap') { status = entry.runtimeAlive ? 'online' : 'waiting'; livenessSource = entry.runtimeAlive ? 'process' : undefined; @@ -476,6 +513,7 @@ export function snapshotToMemberSpawnStatuses( runtimeAlive: entry.runtimeAlive, bootstrapConfirmed: entry.bootstrapConfirmed, hardFailure: entry.hardFailure, + pendingPermissionRequestIds: entry.pendingPermissionRequestIds, firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt, lastHeartbeatAt: entry.lastHeartbeatAt, updatedAt: entry.lastEvaluatedAt, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 6c540a24..677272df 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1535,6 +1535,7 @@ function deriveMemberLaunchState(entry: { runtimeAlive?: boolean; bootstrapConfirmed?: boolean; hardFailure?: boolean; + pendingPermissionRequestIds?: string[]; }): MemberLaunchState { if (entry.hardFailure) { return 'failed_to_start'; @@ -1542,6 +1543,9 @@ function deriveMemberLaunchState(entry: { if (entry.bootstrapConfirmed) { return 'confirmed_alive'; } + if ((entry.pendingPermissionRequestIds?.length ?? 0) > 0) { + return 'runtime_pending_permission'; + } if (entry.runtimeAlive || entry.agentToolAccepted) { return 'runtime_pending_bootstrap'; } @@ -2846,16 +2850,20 @@ function buildGeminiPostLaunchHydrationPrompt( const status = run.memberSpawnStatuses.get(member.name); const label = status?.launchState === 'failed_to_start' - ? `failed to start${status.hardFailureReason ? ` — ${status.hardFailureReason}` : status.error ? ` — ${status.error}` : ''}` + ? `failed to start${status.hardFailureReason ? ` - ${status.hardFailureReason}` : status.error ? ` - ${status.error}` : ''}` : status?.launchState === 'confirmed_alive' ? 'bootstrap confirmed' - : status?.runtimeAlive - ? 'runtime online and ready for instructions' - : status?.launchState === 'runtime_pending_bootstrap' - ? 'spawn accepted, runtime not confirmed yet' - : status?.status === 'spawning' - ? 'spawn in progress' - : 'runtime state unclear'; + : status?.launchState === 'runtime_pending_permission' + ? status?.runtimeAlive + ? 'runtime online and waiting for permission approval' + : 'waiting for permission approval' + : status?.runtimeAlive + ? 'runtime online and ready for instructions' + : status?.launchState === 'runtime_pending_bootstrap' + ? 'spawn accepted, runtime not confirmed yet' + : status?.status === 'spawning' + ? 'spawn in progress' + : 'runtime state unclear'; return `- @${member.name}: ${label}`; }) .join('\n')}\n` diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 51078850..99f48e50 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -130,7 +130,8 @@ export const MemberDetailDialog = ({ ); const restartInFlight = spawnEntry?.launchState === 'starting' || - spawnEntry?.launchState === 'runtime_pending_bootstrap'; + spawnEntry?.launchState === 'runtime_pending_bootstrap' || + spawnEntry?.launchState === 'runtime_pending_permission'; useEffect(() => { if (!open || !member) { diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts index 23233e5a..f4944494 100644 --- a/src/renderer/components/team/provisioningSteps.ts +++ b/src/renderer/components/team/provisioningSteps.ts @@ -75,7 +75,10 @@ function summarizeLiveLaunchJoinMilestones(params: { heartbeatConfirmedCount += 1; continue; } - if (entry.launchState === 'runtime_pending_bootstrap') { + if ( + entry.launchState === 'runtime_pending_bootstrap' || + entry.launchState === 'runtime_pending_permission' + ) { if (entry.runtimeAlive === true) { processOnlyAliveCount += 1; } else { diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 4100a8f8..3ae2b0dd 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -137,6 +137,9 @@ function isLaunchStillStarting( if (spawnLaunchState === 'failed_to_start') { return false; } + if (spawnLaunchState === 'runtime_pending_permission') { + return false; + } if (spawnLaunchState === 'runtime_pending_bootstrap') { if (runtimeAlive !== true) { return true; @@ -167,6 +170,9 @@ export function getSpawnAwareDotClass( if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') { return SPAWN_DOT_COLORS.error; } + if (spawnLaunchState === 'runtime_pending_permission') { + return 'bg-amber-400 animate-pulse'; + } if ( isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive, keepLaunchSettlingVisuals) ) { @@ -211,6 +217,9 @@ export function getSpawnAwarePresenceLabel( if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') { return SPAWN_PRESENCE_LABELS.error; } + if (spawnLaunchState === 'runtime_pending_permission') { + return 'connecting'; + } if ( isLaunchStillStarting(spawnStatus, spawnLaunchState, runtimeAlive, keepLaunchSettlingVisuals) ) { @@ -249,6 +258,9 @@ export function getSpawnCardClass( ) { return 'member-waiting-shimmer'; } + if (spawnLaunchState === 'runtime_pending_permission') { + return 'member-waiting-shimmer'; + } switch (spawnStatus) { case 'offline': return spawnLaunchState === 'starting' ? 'member-waiting-shimmer opacity-75' : 'opacity-40'; @@ -433,6 +445,7 @@ export function getLaunchAwarePresenceLabel( export type MemberLaunchVisualState = | 'waiting' | 'spawning' + | 'permission_pending' | 'runtime_pending' | 'settling' | 'error' @@ -455,6 +468,8 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState) return 'waiting to start'; case 'spawning': return 'starting'; + case 'permission_pending': + return 'awaiting permission'; case 'runtime_pending': return 'connecting'; case 'settling': @@ -527,6 +542,8 @@ export function buildMemberLaunchPresentation({ if (isTeamAlive !== false || isTeamProvisioning) { if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') { launchVisualState = 'error'; + } else if (spawnLaunchState === 'runtime_pending_permission') { + launchVisualState = 'permission_pending'; } else if ( spawnLaunchState === 'runtime_pending_bootstrap' && spawnStatus === 'online' && diff --git a/src/renderer/utils/memberRuntimeSummary.ts b/src/renderer/utils/memberRuntimeSummary.ts index edf0a61d..95137dec 100644 --- a/src/renderer/utils/memberRuntimeSummary.ts +++ b/src/renderer/utils/memberRuntimeSummary.ts @@ -34,6 +34,7 @@ function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): return ( spawnEntry.launchState === 'starting' || spawnEntry.launchState === 'runtime_pending_bootstrap' || + spawnEntry.launchState === 'runtime_pending_permission' || spawnEntry.status === 'waiting' || spawnEntry.status === 'spawning' ); diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 0e45df7a..e51a6a9e 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -679,6 +679,7 @@ export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | export type MemberLaunchState = | 'starting' | 'runtime_pending_bootstrap' + | 'runtime_pending_permission' | 'confirmed_alive' | 'failed_to_start'; export type TeamLaunchAggregateState = 'clean_success' | 'partial_pending' | 'partial_failure'; @@ -933,6 +934,7 @@ export interface PersistedTeamLaunchMemberState { bootstrapConfirmed: boolean; hardFailure: boolean; hardFailureReason?: string; + pendingPermissionRequestIds?: string[]; firstSpawnAcceptedAt?: string; lastHeartbeatAt?: string; lastRuntimeAliveAt?: string; @@ -1046,6 +1048,8 @@ export interface MemberSpawnStatusEntry { bootstrapConfirmed?: boolean; /** Hard failure observed from spawn/bootstrap/runtime evidence. */ hardFailure?: boolean; + /** Pending runtime permission request ids currently blocking bootstrap. */ + pendingPermissionRequestIds?: string[]; /** ISO timestamp of the first accepted teammate spawn for this member. */ firstSpawnAcceptedAt?: string; /** ISO timestamp of the latest confirmed heartbeat/bootstrap message. */ diff --git a/test/renderer/components/team/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts index 9fa1294d..0b032474 100644 --- a/test/renderer/components/team/members/MemberCard.test.ts +++ b/test/renderer/components/team/members/MemberCard.test.ts @@ -236,6 +236,37 @@ describe('MemberCard starting-state visuals', () => { }); }); + it('shows an awaiting permission badge for teammates blocked on runtime permissions', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'online', + spawnLaunchState: 'runtime_pending_permission', + spawnRuntimeAlive: true, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('awaiting permission'); + expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull(); + expect(host.querySelector('.member-waiting-shimmer')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('shows ready instead of idle for confirmed teammates while launch is still settling', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index b626c0fe..7c6ba261 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -207,6 +207,26 @@ describe('memberHelpers spawn-aware presence', () => { expect(settling.launchStatusLabel).toBe('joining team'); }); + it('surfaces permission-blocked teammates as awaiting permission instead of generic starting', () => { + const permissionPending = buildMemberLaunchPresentation({ + member, + spawnStatus: 'online', + spawnLaunchState: 'runtime_pending_permission', + spawnLivenessSource: 'process', + spawnRuntimeAlive: true, + runtimeAdvisory: undefined, + isLaunchSettling: false, + isTeamAlive: true, + isTeamProvisioning: false, + }); + + expect(permissionPending.presenceLabel).toBe('connecting'); + expect(permissionPending.launchVisualState).toBe('permission_pending'); + expect(permissionPending.launchStatusLabel).toBe('awaiting permission'); + expect(permissionPending.dotClass).toContain('bg-amber-400'); + expect(permissionPending.cardClass).toContain('member-waiting-shimmer'); + }); + it('returns shared launch status labels without changing generic presence labels', () => { expect( buildMemberLaunchPresentation({ diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index b136824d..3da5dbaf 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -269,6 +269,70 @@ describe('buildTeamProvisioningPresentation', () => { expect(presentation?.panelMessage).toBe('1 teammate still joining'); }); + it('counts permission-blocked teammates as still joining while launch is finishing', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-4c', + teamName: 'opencode-team', + state: 'ready', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:08.000Z', + message: 'Launch completed', + messageSeverity: undefined, + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'bob', + agentType: 'engineer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: { + bob: { + status: 'online', + launchState: 'runtime_pending_permission', + updatedAt: '2026-04-13T10:00:07.000Z', + runtimeAlive: true, + livenessSource: 'process', + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + pendingPermissionRequestIds: ['perm_1'], + firstSpawnAcceptedAt: '2026-04-13T10:00:01.000Z', + }, + }, + memberSpawnSnapshot: { + expectedMembers: ['bob'], + summary: { + confirmedCount: 0, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }, + }, + }); + + expect(presentation?.compactTitle).toBe('Finishing launch'); + expect(presentation?.compactDetail).toBe('1 teammate still joining'); + expect(presentation?.panelMessage).toBe('1 teammate still joining'); + }); + it('keeps a generic failed teammate message while launch is still active if only persisted failure counts remain', () => { const presentation = buildTeamProvisioningPresentation({ progress: {