refactor(team): extract member spawn snapshot equality
This commit is contained in:
parent
e2031bf928
commit
0a1e4c6e8b
3 changed files with 293 additions and 102 deletions
|
|
@ -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
|
||||
|
|
|
|||
106
src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts
Normal file
106
src/renderer/store/team/teamMemberSpawnSnapshotEquality.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
186
test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts
Normal file
186
test/renderer/store/teamMemberSpawnSnapshotEquality.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue