fix(team): keep launch pending for dead runtime entries

This commit is contained in:
777genius 2026-05-25 14:53:45 +03:00
parent c033a0cb87
commit 79faaf1f9f
7 changed files with 181 additions and 7 deletions

View file

@ -1240,6 +1240,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
() => buildTeamAgentRuntimeMap(runtimeSnapshot?.members),
[runtimeSnapshot?.members]
);
const runtimeEntries = runtimeSnapshot?.members;
const runtimeRunId = runtimeSnapshot?.runId ?? memberSpawnSnapshot?.runId ?? progress?.runId;
const isLaunchSettling = useMemo(() => {
if (progress?.state !== 'ready') {
@ -1250,9 +1251,10 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
members: props.members,
memberSpawnStatuses,
memberSpawnSnapshot,
memberRuntimeEntries: runtimeEntries,
})
).hasMembersStillJoining;
}, [memberSpawnSnapshot, memberSpawnStatuses, progress?.state, props.members]);
}, [memberSpawnSnapshot, memberSpawnStatuses, progress?.state, props.members, runtimeEntries]);
return (
<MemberList
@ -1324,6 +1326,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
memberSpawnSnapshot,
spawnEntry,
runtimeRunId,
runtimeEntries,
runtimeEntry,
} = useStore(
useShallow((s) => ({
@ -1338,6 +1341,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
s.teamAgentRuntimeByTeam[teamName]?.runId ??
s.memberSpawnSnapshotsByTeam[teamName]?.runId ??
getCurrentProvisioningProgressForTeam(s, teamName)?.runId,
runtimeEntries: s.teamAgentRuntimeByTeam[teamName]?.members,
runtimeEntry: member ? s.teamAgentRuntimeByTeam[teamName]?.members[member.name] : undefined,
}))
);
@ -1350,9 +1354,10 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
members: launchMembers,
memberSpawnStatuses,
memberSpawnSnapshot,
memberRuntimeEntries: runtimeEntries,
})
).hasMembersStillJoining;
}, [launchMembers, memberSpawnSnapshot, memberSpawnStatuses, progress?.state]);
}, [launchMembers, memberSpawnSnapshot, memberSpawnStatuses, progress?.state, runtimeEntries]);
return (
<MemberDetailDialog

View file

@ -84,6 +84,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({
memberSpawnStatuses,
spawnEntry,
runtimeRunId,
runtimeEntries,
runtimeEntry,
leadActivity,
} = useStore(
@ -109,6 +110,9 @@ export const MemberHoverCard = memo(function MemberHoverCard({
runtimeRunId: effectiveTeamName
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.runId
: undefined,
runtimeEntries: effectiveTeamName
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.members
: undefined,
runtimeEntry: effectiveTeamName
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.members[name]
: undefined,
@ -126,6 +130,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({
members: teamMembers,
memberSpawnStatuses,
memberSpawnSnapshot,
memberRuntimeEntries: runtimeEntries,
});
const isLaunchSettling =
progress?.state === 'ready' && getLaunchJoinState(launchJoinMilestones).hasMembersStillJoining;

View file

@ -3,6 +3,7 @@ import { isLeadMember } from '@shared/utils/leadDetection';
import type {
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
TeamAgentRuntimeEntry,
TeamProvisioningProgress,
} from '@shared/types';
@ -39,6 +40,11 @@ type MemberSpawnStatusCollection =
| Map<string, MemberSpawnStatusEntry>
| undefined;
type TeamAgentRuntimeEntryCollection =
| Record<string, TeamAgentRuntimeEntry>
| Map<string, TeamAgentRuntimeEntry>
| undefined;
function getSpawnEntry(
memberSpawnStatuses: MemberSpawnStatusCollection,
memberName: string
@ -52,6 +58,19 @@ function getSpawnEntry(
return memberSpawnStatuses[memberName];
}
function getRuntimeEntry(
memberRuntimeEntries: TeamAgentRuntimeEntryCollection,
memberName: string
): TeamAgentRuntimeEntry | undefined {
if (!memberRuntimeEntries) {
return undefined;
}
if (memberRuntimeEntries instanceof Map) {
return memberRuntimeEntries.get(memberName);
}
return memberRuntimeEntries[memberName];
}
function parseStatusUpdatedAtMs(value: string | undefined): number | null {
if (!value) {
return null;
@ -72,6 +91,16 @@ function isStrongRuntimeProcessSpawnEntry(entry: MemberSpawnStatusEntry): boolea
);
}
function isConfirmedSpawnEntry(entry: MemberSpawnStatusEntry): boolean {
return entry.launchState === 'confirmed_alive' || entry.bootstrapConfirmed === true;
}
function runtimeEntryContradictsConfirmedJoin(
runtimeEntry: TeamAgentRuntimeEntry | undefined
): boolean {
return runtimeEntry?.alive === false;
}
function shouldPreferSnapshotEntryOverLive(
liveEntry: MemberSpawnStatusEntry | undefined,
snapshotEntry: MemberSpawnStatusEntry | undefined,
@ -98,6 +127,7 @@ function summarizeLiveLaunchJoinMilestones(params: {
memberSpawnStatuses?: MemberSpawnStatusCollection;
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
memberSpawnSnapshotUpdatedAt?: string;
memberRuntimeEntries?: TeamAgentRuntimeEntryCollection;
}): Omit<LaunchJoinMilestones, 'expectedTeammateCount'> & {
observedTeammateCount: number;
} {
@ -137,6 +167,13 @@ function summarizeLiveLaunchJoinMilestones(params: {
skippedSpawnCount += 1;
continue;
}
if (
isConfirmedSpawnEntry(entry) &&
runtimeEntryContradictsConfirmedJoin(getRuntimeEntry(params.memberRuntimeEntries, memberName))
) {
pendingSpawnCount += 1;
continue;
}
if (entry.launchState === 'confirmed_alive') {
heartbeatConfirmedCount += 1;
continue;
@ -172,6 +209,7 @@ export function getLaunchJoinMilestonesFromMembers({
members,
memberSpawnStatuses,
memberSpawnSnapshot,
memberRuntimeEntries,
}: {
members: readonly LaunchJoinMemberLike[];
memberSpawnStatuses?: MemberSpawnStatusCollection;
@ -181,6 +219,7 @@ export function getLaunchJoinMilestonesFromMembers({
> & {
statuses?: MemberSpawnStatusesSnapshot['statuses'];
};
memberRuntimeEntries?: TeamAgentRuntimeEntryCollection;
}): LaunchJoinMilestones {
const removedTeammateNameSet = new Set(
members
@ -209,6 +248,7 @@ export function getLaunchJoinMilestonesFromMembers({
memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
memberRuntimeEntries,
});
if (snapshotSummary) {

View file

@ -51,9 +51,10 @@ export function useTeamProvisioningPresentation(teamName: string): {
members: teamMembers,
memberSpawnStatuses,
memberSpawnSnapshot,
memberRuntimeEntries: runtimeSnapshot?.members,
t,
}),
[memberSpawnSnapshot, memberSpawnStatuses, progress, teamMembers, t]
[memberSpawnSnapshot, memberSpawnStatuses, progress, runtimeSnapshot?.members, teamMembers, t]
);
const memberDiagnostics = useMemo(
() =>

View file

@ -9,6 +9,7 @@ import { isLeadMember } from '@shared/utils/leadDetection';
import type {
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
TeamAgentRuntimeEntry,
TeamProvisioningProgress,
} from '@shared/types';
@ -17,6 +18,11 @@ type MemberSpawnStatusCollection =
| Map<string, MemberSpawnStatusEntry>
| undefined;
type TeamAgentRuntimeEntryCollection =
| Record<string, TeamAgentRuntimeEntry>
| Map<string, TeamAgentRuntimeEntry>
| undefined;
interface ProvisioningMemberLike {
name: string;
removedAt?: number;
@ -892,6 +898,7 @@ export function buildTeamProvisioningPresentation({
members,
memberSpawnStatuses,
memberSpawnSnapshot,
memberRuntimeEntries,
t,
}: {
progress: TeamProvisioningProgress | null | undefined;
@ -903,6 +910,7 @@ export function buildTeamProvisioningPresentation({
> & {
statuses?: MemberSpawnStatusesSnapshot['statuses'];
};
memberRuntimeEntries?: TeamAgentRuntimeEntryCollection;
t?: unknown;
}): TeamProvisioningPresentation | null {
if (!progress) {
@ -934,6 +942,7 @@ export function buildTeamProvisioningPresentation({
members,
memberSpawnStatuses,
memberSpawnSnapshot,
memberRuntimeEntries,
});
const failedSpawnDetails = getFailedSpawnDetails({
memberSpawnStatuses,

View file

@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest';
import { getLaunchJoinMilestonesFromMembers } from '@renderer/components/team/provisioningSteps';
import { describe, expect, it } from 'vitest';
const members = [{ name: 'alice' }, { name: 'bob' }, { name: 'tom' }, { name: 'jane' }];
@ -194,4 +193,54 @@ describe('getLaunchJoinMilestonesFromMembers', () => {
expect(milestones.pendingSpawnCount).toBe(1);
expect(milestones.expectedTeammateCount).toBe(4);
});
it('does not count confirmed spawn as joined when runtime snapshot is unavailable', () => {
const milestones = getLaunchJoinMilestonesFromMembers({
members,
memberSpawnStatuses: {
alice: {
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
updatedAt: '2026-04-24T12:00:00.000Z',
},
bob: {
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
updatedAt: '2026-04-24T12:00:01.000Z',
},
tom: {
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
updatedAt: '2026-04-24T12:00:00.000Z',
},
jane: {
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
updatedAt: '2026-04-24T12:00:00.000Z',
},
},
memberRuntimeEntries: {
bob: {
memberName: 'bob',
alive: false,
restartable: true,
livenessKind: 'registered_only',
runtimeDiagnostic: 'registered runtime metadata without live process',
updatedAt: '2026-04-24T12:00:02.000Z',
},
},
});
expect(milestones.heartbeatConfirmedCount).toBe(3);
expect(milestones.pendingSpawnCount).toBe(1);
expect(milestones.expectedTeammateCount).toBe(4);
});
});

View file

@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest';
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import { describe, expect, it } from 'vitest';
describe('buildTeamProvisioningPresentation', () => {
it('uses a lead-online compact detail for ready teams without teammates', () => {
@ -1607,6 +1606,72 @@ describe('buildTeamProvisioningPresentation', () => {
expect(presentation?.currentStepIndex).toBe(4);
});
it('keeps ready launch in finishing state when runtime snapshot contradicts confirmed spawn', () => {
const presentation = buildTeamProvisioningPresentation({
progress: {
runId: 'run-5b',
teamName: 'codex-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: 'confirmed_alive',
updatedAt: '2026-04-13T10:00:07.000Z',
runtimeAlive: true,
livenessSource: 'heartbeat',
bootstrapConfirmed: true,
hardFailure: false,
agentToolAccepted: true,
firstSpawnAcceptedAt: '2026-04-13T10:00:01.000Z',
lastHeartbeatAt: '2026-04-13T10:00:07.000Z',
},
},
memberRuntimeEntries: {
bob: {
memberName: 'bob',
alive: false,
restartable: true,
livenessKind: 'registered_only',
runtimeDiagnostic: 'registered runtime metadata without live process',
updatedAt: '2026-04-13T10:00:08.000Z',
},
},
});
expect(presentation?.compactTitle).toBe('Finishing launch');
expect(presentation?.compactDetail).toBe('1 teammate still joining');
expect(presentation?.successMessage).toBe('Finishing launch');
expect(presentation?.currentStepIndex).toBe(2);
});
it('ignores removed teammates that still linger in persisted expectedMembers', () => {
const presentation = buildTeamProvisioningPresentation({
progress: {