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,
|
invalidateTeamLocalStateEpoch,
|
||||||
isTeamLocalStateEpochCurrent,
|
isTeamLocalStateEpochCurrent,
|
||||||
} from '../team/teamLocalStateEpoch';
|
} from '../team/teamLocalStateEpoch';
|
||||||
|
import { areMemberSpawnSnapshotsSemanticallyEqual } from '../team/teamMemberSpawnSnapshotEquality';
|
||||||
import {
|
import {
|
||||||
clearAllMemberSpawnStatusesIpcBackoffs,
|
clearAllMemberSpawnStatusesIpcBackoffs,
|
||||||
clearMemberSpawnStatusesIpcBackoff,
|
clearMemberSpawnStatusesIpcBackoff,
|
||||||
|
|
@ -120,7 +121,6 @@ import type {
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
NotificationTarget,
|
NotificationTarget,
|
||||||
PersistedTeamLaunchSummary,
|
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
RetryFailedOpenCodeSecondaryLanesResult,
|
RetryFailedOpenCodeSecondaryLanesResult,
|
||||||
SendMessageRequest,
|
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(
|
function maybeLogMemberSpawnUiEqualSuppressed(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
runId: string | null | undefined
|
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