fix(team): align permission-blocked launch state
This commit is contained in:
parent
2b96adda33
commit
8cd3f04c20
12 changed files with 203 additions and 13 deletions
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export type GraphNodeState =
|
|||
export type GraphLaunchVisualState =
|
||||
| 'waiting'
|
||||
| 'spawning'
|
||||
| 'permission_pending'
|
||||
| 'runtime_pending'
|
||||
| 'settling'
|
||||
| 'error';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' &&
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue