From 146b839b9c5b9db516692155324aa0ae7f2b26c2 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 23 Apr 2026 02:26:50 +0300 Subject: [PATCH] fix(team): keep permission-pending launches from reading ready --- .../services/team/TeamProvisioningService.ts | 73 +++++++++-- .../team/TeamProvisioningService.test.ts | 120 ++++++++++++++++++ 2 files changed, 181 insertions(+), 12 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index d2229412..d5e74dba 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -11857,6 +11857,19 @@ export class TeamProvisioningService { runtimeAlivePendingCount: number; } ): string { + const permissionPendingCount = this.countRunPermissionPendingMembers(run); + if ( + launchSummary.pendingCount > 0 && + permissionPendingCount > 0 && + permissionPendingCount === launchSummary.pendingCount + ) { + return `${prefix} — ${ + permissionPendingCount === 1 + ? '1 teammate awaiting permission approval' + : `${permissionPendingCount} teammates awaiting permission approval` + }`; + } + const stillStartingCount = Math.max( 0, launchSummary.pendingCount - launchSummary.runtimeAlivePendingCount @@ -11891,6 +11904,30 @@ export class TeamProvisioningService { return this.buildPendingBootstrapStatusMessage(prefix, run, launchSummary); } + const allPendingMembers = snapshot.expectedMembers.filter((memberName) => { + const member = snapshot.members[memberName]; + if (!member) { + return false; + } + return member.launchState !== 'confirmed_alive' && member.launchState !== 'failed_to_start'; + }); + if ( + allPendingMembers.length > 0 && + allPendingMembers.every((memberName) => { + const member = snapshot.members[memberName]; + return ( + member?.launchState === 'runtime_pending_permission' || + (member?.pendingPermissionRequestIds?.length ?? 0) > 0 + ); + }) + ) { + return `${prefix} — ${ + allPendingMembers.length === 1 + ? '1 teammate awaiting permission approval' + : `${allPendingMembers.length} teammates awaiting permission approval` + }`; + } + const primaryExpectedMembers = new Set( snapshot.bootstrapExpectedMembers ?? run.expectedMembers ); @@ -11922,6 +11959,28 @@ export class TeamProvisioningService { return statuses; } + private countRunPermissionPendingMembers(run: ProvisioningRun): number { + let count = 0; + for (const expected of run.expectedMembers ?? []) { + const entry = run.memberSpawnStatuses.get(expected) ?? createInitialMemberSpawnStatusEntry(); + if (entry.launchState === 'runtime_pending_permission') { + count += 1; + } + } + return count; + } + + private hasPendingLaunchMembers( + run: ProvisioningRun, + launchSummary: { + pendingCount: number; + }, + snapshot?: PersistedTeamLaunchSnapshot | null + ): boolean { + const expectedCount = snapshot?.expectedMembers.length ?? run.expectedMembers?.length ?? 0; + return launchSummary.pendingCount > 0 && expectedCount > 0; + } + private buildLiveLaunchSnapshotForRun( run: ProvisioningRun, launchPhase: PersistedTeamLaunchPhase = run.provisioningComplete ? 'finished' : 'active' @@ -15443,14 +15502,9 @@ export class TeamProvisioningService { : this.getFailedSpawnMembers(run); const launchSummary = persistedLaunchSnapshot?.summary ?? this.getMemberLaunchSummary(run); const hasSpawnFailures = failedSpawnMembers.length > 0; - const stillStartingCount = Math.max( - 0, - launchSummary.pendingCount - launchSummary.runtimeAlivePendingCount - ); const hasPendingBootstrap = !hasSpawnFailures && - stillStartingCount > 0 && - (persistedLaunchSnapshot?.expectedMembers.length ?? run.expectedMembers?.length ?? 0) > 0; + this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot); const readyMessage = hasSpawnFailures ? `Launch completed with teammate errors — ${failedSpawnMembers .map((member) => member.name) @@ -15622,14 +15676,9 @@ export class TeamProvisioningService { : this.getFailedSpawnMembers(run); const launchSummary = persistedLaunchSnapshot?.summary ?? this.getMemberLaunchSummary(run); const hasSpawnFailures = failedSpawnMembers.length > 0; - const stillStartingCount = Math.max( - 0, - launchSummary.pendingCount - launchSummary.runtimeAlivePendingCount - ); const hasPendingBootstrap = !hasSpawnFailures && - stillStartingCount > 0 && - (persistedLaunchSnapshot?.expectedMembers.length ?? run.expectedMembers.length) > 0; + this.hasPendingLaunchMembers(run, launchSummary, persistedLaunchSnapshot); const progress = updateProgress( run, 'ready', diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index bedda02d..38f0d561 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -2001,6 +2001,126 @@ describe('TeamProvisioningService', () => { expect(message).toBe('Finishing launch - waiting for secondary runtime lane: bob'); }); + it('uses permission-pending copy when the remaining mixed-team member is awaiting approval', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'mixed-team', + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([ + [ + 'alice', + createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + }), + ], + ]), + }); + run.isLaunch = true; + run.mixedSecondaryLanes = [ + { + laneId: 'secondary:opencode:bob', + providerId: 'opencode', + member: { + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + }, + runId: 'opencode-run-1', + state: 'launching', + result: null, + warnings: [], + diagnostics: [], + }, + ]; + + const message = (svc as any).buildAggregatePendingLaunchMessage( + 'Finishing launch', + run, + { + confirmedCount: 1, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }, + { + version: 2, + teamName: 'mixed-team', + updatedAt: '2026-04-22T12:00:00.000Z', + launchPhase: 'active', + expectedMembers: ['alice', 'bob'], + bootstrapExpectedMembers: ['alice'], + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + bob: { + name: 'bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'runtime_pending_permission', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + pendingPermissionRequestIds: ['perm-1'], + lastEvaluatedAt: '2026-04-22T12:00:00.000Z', + }, + }, + summary: { + confirmedCount: 1, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }, + teamLaunchState: 'partial_pending', + } + ); + + expect(message).toBe('Finishing launch — 1 teammate awaiting permission approval'); + }); + + it('keeps launch pending when the only remaining teammate is permission-blocked but already online', () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([ + [ + 'alice', + createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'runtime_pending_permission', + runtimeAlive: true, + agentToolAccepted: true, + bootstrapConfirmed: false, + pendingPermissionRequestIds: ['perm-1'], + }), + ], + ]), + }); + const launchSummary = (svc as any).getMemberLaunchSummary(run); + + expect((svc as any).hasPendingLaunchMembers(run, launchSummary, null)).toBe(true); + expect((svc as any).buildPendingBootstrapStatusMessage('Finishing launch', run, launchSummary)).toBe( + 'Finishing launch — 1 teammate awaiting permission approval' + ); + }); + it('launches the OpenCode secondary lane with side-lane provider and member runtime identity', async () => { const svc = new TeamProvisioningService(); const adapterLaunch = vi.fn(async (input: Record) => ({