fix(team): keep launch pending for dead runtime entries
This commit is contained in:
parent
c033a0cb87
commit
79faaf1f9f
7 changed files with 181 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue