fix(team): align permission-blocked launch state

This commit is contained in:
777genius 2026-04-23 01:05:54 +03:00
parent 2b96adda33
commit 8cd3f04c20
12 changed files with 203 additions and 13 deletions

View file

@ -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':

View file

@ -20,6 +20,7 @@ export type GraphNodeState =
export type GraphLaunchVisualState =
| 'waiting'
| 'spawning'
| 'permission_pending'
| 'runtime_pending'
| 'settling'
| 'error';

View file

@ -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,

View file

@ -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`

View file

@ -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) {

View file

@ -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 {

View file

@ -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' &&

View file

@ -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'
);

View file

@ -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. */

View file

@ -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');

View file

@ -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({

View file

@ -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: {