From 0a1e4c6e8b85ec843fa31fb0fb9b047a7432db7a Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 22 May 2026 10:56:12 +0300 Subject: [PATCH] refactor(team): extract member spawn snapshot equality --- src/renderer/store/slices/teamSlice.ts | 103 +--------- .../team/teamMemberSpawnSnapshotEquality.ts | 106 ++++++++++ .../teamMemberSpawnSnapshotEquality.test.ts | 186 ++++++++++++++++++ 3 files changed, 293 insertions(+), 102 deletions(-) create mode 100644 src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts create mode 100644 test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 8947b797..07cc892f 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -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, - right: Record -): 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 diff --git a/src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts b/src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts new file mode 100644 index 00000000..3f5990e2 --- /dev/null +++ b/src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts @@ -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, + right: Record +): 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) + ); +} diff --git a/test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts b/test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts new file mode 100644 index 00000000..c372ef09 --- /dev/null +++ b/test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts @@ -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 { + 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 { + 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 { + 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); + }); +});