refactor(team): extract member spawn snapshot equality

This commit is contained in:
777genius 2026-05-22 10:56:12 +03:00
parent e2031bf928
commit 0a1e4c6e8b
3 changed files with 293 additions and 102 deletions

View file

@ -51,6 +51,7 @@ import {
invalidateTeamLocalStateEpoch,
isTeamLocalStateEpochCurrent,
} from '../team/teamLocalStateEpoch';
import { areMemberSpawnSnapshotsSemanticallyEqual } from '../team/teamMemberSpawnSnapshotEquality';
import {
clearAllMemberSpawnStatusesIpcBackoffs,
clearMemberSpawnStatusesIpcBackoff,
@ -120,7 +121,6 @@ import type {
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
NotificationTarget,
PersistedTeamLaunchSummary,
ResolvedTeamMember,
RetryFailedOpenCodeSecondaryLanesResult,
SendMessageRequest,
@ -626,107 +626,6 @@ function fetchTeamDataFresh(
);
}
function areLaunchSummaryCountsEqual(
left: PersistedTeamLaunchSummary | undefined,
right: PersistedTeamLaunchSummary | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
return (
left.confirmedCount === right.confirmedCount &&
left.pendingCount === right.pendingCount &&
left.failedCount === right.failedCount &&
left.skippedCount === right.skippedCount &&
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount &&
left.shellOnlyPendingCount === right.shellOnlyPendingCount &&
left.runtimeProcessPendingCount === right.runtimeProcessPendingCount &&
left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount &&
left.noRuntimePendingCount === right.noRuntimePendingCount &&
left.permissionPendingCount === right.permissionPendingCount
);
}
function areExpectedMembersEqual(
left: readonly string[] | undefined,
right: readonly string[] | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
}
function areMemberSpawnStatusEntriesEqual(
left: MemberSpawnStatusEntry | undefined,
right: MemberSpawnStatusEntry | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
const leftPendingPermissionIds = [...(left.pendingPermissionRequestIds ?? [])].sort();
const rightPendingPermissionIds = [...(right.pendingPermissionRequestIds ?? [])].sort();
// Renderer equality intentionally ignores raw timing fields that do not change
// visible member status. This suppresses heartbeat-only churn in TeamDetailView.
return (
left.status === right.status &&
left.launchState === right.launchState &&
left.error === right.error &&
left.hardFailureReason === right.hardFailureReason &&
left.skippedForLaunch === right.skippedForLaunch &&
left.skipReason === right.skipReason &&
left.skippedAt === right.skippedAt &&
left.livenessSource === right.livenessSource &&
left.runtimeAlive === right.runtimeAlive &&
left.runtimeModel === right.runtimeModel &&
left.livenessKind === right.livenessKind &&
left.runtimeDiagnostic === right.runtimeDiagnostic &&
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
left.bootstrapConfirmed === right.bootstrapConfirmed &&
left.hardFailure === right.hardFailure &&
leftPendingPermissionIds.length === rightPendingPermissionIds.length &&
leftPendingPermissionIds.every((value, index) => value === rightPendingPermissionIds[index])
);
}
function areMemberSpawnStatusesEqual(
left: Record<string, MemberSpawnStatusEntry>,
right: Record<string, MemberSpawnStatusEntry>
): boolean {
if (left === right) return true;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
for (const key of leftKeys) {
if (!(key in right)) {
return false;
}
if (!areMemberSpawnStatusEntriesEqual(left[key], right[key])) {
return false;
}
}
return true;
}
function areMemberSpawnSnapshotsSemanticallyEqual(
left: MemberSpawnStatusesSnapshot | undefined,
right: MemberSpawnStatusesSnapshot
): boolean {
if (!left) return false;
return (
left.runId === right.runId &&
left.teamLaunchState === right.teamLaunchState &&
left.launchPhase === right.launchPhase &&
left.source === right.source &&
areExpectedMembersEqual(left.expectedMembers, right.expectedMembers) &&
areLaunchSummaryCountsEqual(left.summary, right.summary) &&
areMemberSpawnStatusesEqual(left.statuses, right.statuses)
);
}
function maybeLogMemberSpawnUiEqualSuppressed(
teamName: string,
runId: string | null | undefined

View file

@ -0,0 +1,106 @@
import type {
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
PersistedTeamLaunchSummary,
} from '@shared/types';
export function areLaunchSummaryCountsEqual(
left: PersistedTeamLaunchSummary | undefined,
right: PersistedTeamLaunchSummary | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
return (
left.confirmedCount === right.confirmedCount &&
left.pendingCount === right.pendingCount &&
left.failedCount === right.failedCount &&
left.skippedCount === right.skippedCount &&
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount &&
left.shellOnlyPendingCount === right.shellOnlyPendingCount &&
left.runtimeProcessPendingCount === right.runtimeProcessPendingCount &&
left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount &&
left.noRuntimePendingCount === right.noRuntimePendingCount &&
left.permissionPendingCount === right.permissionPendingCount
);
}
export function areExpectedMembersEqual(
left: readonly string[] | undefined,
right: readonly string[] | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
}
export function areMemberSpawnStatusEntriesEqual(
left: MemberSpawnStatusEntry | undefined,
right: MemberSpawnStatusEntry | undefined
): boolean {
if (left === right) return true;
if (!left || !right) return left === right;
const leftPendingPermissionIds = [...(left.pendingPermissionRequestIds ?? [])].sort();
const rightPendingPermissionIds = [...(right.pendingPermissionRequestIds ?? [])].sort();
// Renderer equality intentionally ignores raw timing fields that do not change
// visible member status. This suppresses heartbeat-only churn in TeamDetailView.
return (
left.status === right.status &&
left.launchState === right.launchState &&
left.error === right.error &&
left.hardFailureReason === right.hardFailureReason &&
left.skippedForLaunch === right.skippedForLaunch &&
left.skipReason === right.skipReason &&
left.skippedAt === right.skippedAt &&
left.livenessSource === right.livenessSource &&
left.runtimeAlive === right.runtimeAlive &&
left.runtimeModel === right.runtimeModel &&
left.livenessKind === right.livenessKind &&
left.runtimeDiagnostic === right.runtimeDiagnostic &&
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
left.bootstrapConfirmed === right.bootstrapConfirmed &&
left.hardFailure === right.hardFailure &&
leftPendingPermissionIds.length === rightPendingPermissionIds.length &&
leftPendingPermissionIds.every((value, index) => value === rightPendingPermissionIds[index])
);
}
export function areMemberSpawnStatusesEqual(
left: Record<string, MemberSpawnStatusEntry>,
right: Record<string, MemberSpawnStatusEntry>
): boolean {
if (left === right) return true;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
for (const key of leftKeys) {
if (!(key in right)) {
return false;
}
if (!areMemberSpawnStatusEntriesEqual(left[key], right[key])) {
return false;
}
}
return true;
}
export function areMemberSpawnSnapshotsSemanticallyEqual(
left: MemberSpawnStatusesSnapshot | undefined,
right: MemberSpawnStatusesSnapshot
): boolean {
if (!left) return false;
return (
left.runId === right.runId &&
left.teamLaunchState === right.teamLaunchState &&
left.launchPhase === right.launchPhase &&
left.source === right.source &&
areExpectedMembersEqual(left.expectedMembers, right.expectedMembers) &&
areLaunchSummaryCountsEqual(left.summary, right.summary) &&
areMemberSpawnStatusesEqual(left.statuses, right.statuses)
);
}

View file

@ -0,0 +1,186 @@
import { describe, expect, it } from 'vitest';
import {
areExpectedMembersEqual,
areLaunchSummaryCountsEqual,
areMemberSpawnSnapshotsSemanticallyEqual,
areMemberSpawnStatusEntriesEqual,
areMemberSpawnStatusesEqual,
} from '../../../src/renderer/store/team/teamMemberSpawnSnapshotEquality';
import type {
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
PersistedTeamLaunchSummary,
} from '../../../src/shared/types';
function createSummary(
overrides: Partial<PersistedTeamLaunchSummary> = {}
): PersistedTeamLaunchSummary {
return {
confirmedCount: 1,
pendingCount: 0,
failedCount: 0,
skippedCount: 0,
runtimeAlivePendingCount: 0,
shellOnlyPendingCount: 0,
runtimeProcessPendingCount: 0,
runtimeCandidatePendingCount: 0,
noRuntimePendingCount: 0,
permissionPendingCount: 0,
...overrides,
};
}
function createStatusEntry(
overrides: Partial<MemberSpawnStatusEntry> = {}
): MemberSpawnStatusEntry {
return {
status: 'online',
launchState: 'confirmed_alive',
updatedAt: '2026-05-22T10:00:00.000Z',
livenessSource: 'heartbeat',
runtimeAlive: true,
runtimeModel: 'gpt-5.3-codex',
livenessKind: 'confirmed_bootstrap',
runtimeDiagnostic: 'Ready',
runtimeDiagnosticSeverity: 'info',
bootstrapConfirmed: true,
hardFailure: false,
pendingPermissionRequestIds: ['perm-a', 'perm-b'],
...overrides,
};
}
function createSnapshot(
overrides: Partial<MemberSpawnStatusesSnapshot> = {}
): MemberSpawnStatusesSnapshot {
return {
statuses: {
alice: createStatusEntry(),
},
runId: 'run-1',
teamLaunchState: 'clean_success',
launchPhase: 'active',
expectedMembers: ['alice'],
updatedAt: '2026-05-22T10:00:00.000Z',
summary: createSummary(),
source: 'live',
...overrides,
};
}
describe('teamMemberSpawnSnapshotEquality', () => {
it('compares launch summaries by visible counts', () => {
expect(areLaunchSummaryCountsEqual(createSummary(), createSummary())).toBe(true);
expect(
areLaunchSummaryCountsEqual(createSummary(), createSummary({ permissionPendingCount: 1 }))
).toBe(false);
expect(areLaunchSummaryCountsEqual(undefined, undefined)).toBe(true);
expect(areLaunchSummaryCountsEqual(undefined, createSummary())).toBe(false);
});
it('compares expected members in stable order', () => {
expect(areExpectedMembersEqual(['alice', 'bob'], ['alice', 'bob'])).toBe(true);
expect(areExpectedMembersEqual(['alice', 'bob'], ['bob', 'alice'])).toBe(false);
expect(areExpectedMembersEqual(undefined, undefined)).toBe(true);
expect(areExpectedMembersEqual(undefined, [])).toBe(false);
});
it('ignores non-visible status churn and unordered pending permission ids', () => {
const left = createStatusEntry({
pendingPermissionRequestIds: ['perm-b', 'perm-a'],
updatedAt: '2026-05-22T10:00:00.000Z',
agentToolAccepted: true,
firstSpawnAcceptedAt: '2026-05-22T10:00:01.000Z',
lastHeartbeatAt: '2026-05-22T10:00:02.000Z',
livenessLastCheckedAt: '2026-05-22T10:00:03.000Z',
bootstrapStalled: true,
});
const right = createStatusEntry({
pendingPermissionRequestIds: ['perm-a', 'perm-b'],
updatedAt: '2026-05-22T10:05:00.000Z',
agentToolAccepted: false,
firstSpawnAcceptedAt: '2026-05-22T10:05:01.000Z',
lastHeartbeatAt: '2026-05-22T10:05:02.000Z',
livenessLastCheckedAt: '2026-05-22T10:05:03.000Z',
bootstrapStalled: false,
});
expect(areMemberSpawnStatusEntriesEqual(left, right)).toBe(true);
});
it('detects visible status entry changes', () => {
expect(
areMemberSpawnStatusEntriesEqual(
createStatusEntry(),
createStatusEntry({ runtimeDiagnosticSeverity: 'warning' })
)
).toBe(false);
expect(
areMemberSpawnStatusEntriesEqual(
createStatusEntry(),
createStatusEntry({ pendingPermissionRequestIds: ['perm-a'] })
)
).toBe(false);
});
it('compares per-member status maps by keys and semantic entries', () => {
expect(
areMemberSpawnStatusesEqual(
{
alice: createStatusEntry(),
bob: createStatusEntry({ runtimeModel: 'gpt-5.4' }),
},
{
bob: createStatusEntry({ runtimeModel: 'gpt-5.4' }),
alice: createStatusEntry(),
}
)
).toBe(true);
expect(
areMemberSpawnStatusesEqual(
{
alice: createStatusEntry(),
},
{
alice: createStatusEntry(),
bob: createStatusEntry(),
}
)
).toBe(false);
});
it('compares snapshots by semantic launch fields and ignores snapshot updatedAt churn', () => {
const left = createSnapshot({
updatedAt: '2026-05-22T10:00:00.000Z',
});
const right = createSnapshot({
updatedAt: '2026-05-22T10:05:00.000Z',
statuses: {
alice: createStatusEntry({
pendingPermissionRequestIds: ['perm-b', 'perm-a'],
updatedAt: '2026-05-22T10:05:00.000Z',
}),
},
});
expect(areMemberSpawnSnapshotsSemanticallyEqual(left, right)).toBe(true);
});
it('detects semantic snapshot changes', () => {
expect(
areMemberSpawnSnapshotsSemanticallyEqual(
createSnapshot(),
createSnapshot({ runId: 'run-2' })
)
).toBe(false);
expect(
areMemberSpawnSnapshotsSemanticallyEqual(
createSnapshot(),
createSnapshot({ expectedMembers: ['alice', 'bob'] })
)
).toBe(false);
expect(areMemberSpawnSnapshotsSemanticallyEqual(undefined, createSnapshot())).toBe(false);
});
});