perf(renderer): suppress launch structural refreshes

This commit is contained in:
777genius 2026-05-04 11:12:20 +03:00
parent 24d96b5bec
commit b1d27c1382
2 changed files with 481 additions and 2 deletions

View file

@ -296,6 +296,14 @@ export function initializeNotificationListeners(): () => void {
const isProvisioningProgressActiveForProcessLite = (
progress: Pick<TeamProvisioningProgress, 'state'> | null
): boolean => progress != null && ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE.has(progress.state);
const hasActiveProvisioningRunForTeam = (teamName: string): boolean => {
return isProvisioningProgressActiveForProcessLite(
getCurrentProvisioningProgressForTeam(useStore.getState(), teamName)
);
};
const shouldDeferAutomaticTeamDataRefreshDuringLaunch = (teamName: string): boolean => {
return isTeamProcessLiteFanoutEnabled() && hasActiveProvisioningRunForTeam(teamName);
};
const addPendingGlobalRefreshDiagnostic = (
pending: Map<string, Set<string>>,
teamName: string,
@ -648,6 +656,9 @@ export function initializeNotificationListeners(): () => void {
for (const teamName of visibleTeamNames) {
const teamData = selectTeamDataForName(state, teamName);
if (teamData?.teamName !== teamName) {
if (shouldDeferAutomaticTeamDataRefreshDuringLaunch(teamName)) {
continue;
}
if (!isTeamDataRefreshPending(teamName)) {
void state.refreshTeamData(teamName, { withDedup: true });
}
@ -679,6 +690,9 @@ export function initializeNotificationListeners(): () => void {
const currentTeamData = selectTeamDataForName(current, teamName);
if (currentTeamData?.teamName !== teamName) {
if (shouldDeferAutomaticTeamDataRefreshDuringLaunch(teamName)) {
continue;
}
if (!isTeamDataRefreshPending(teamName)) {
void current.refreshTeamData(teamName, { withDedup: true });
}
@ -917,7 +931,10 @@ export function initializeNotificationListeners(): () => void {
activeTab: getFocusedVisibleTeamName() === event.teamName,
});
};
const cancelProcessLiteStructuralReconcile = (teamName: string): void => {
const cancelProcessLiteStructuralReconcile = (
teamName: string,
reason = 'event:process-lite:structural-reconcile:cancelled-by-structural'
): void => {
const existing = processLiteStructuralReconcileTimers.get(teamName);
if (!existing) {
return;
@ -928,11 +945,59 @@ export function initializeNotificationListeners(): () => void {
teamName,
surface: 'team-change-listener',
phase: 'skipped',
reason: 'event:process-lite:structural-reconcile:cancelled-by-structural',
reason,
operation: 'refreshTeamData',
});
};
const shouldSuppressProcessLiteStructuralReconcileDuringLaunch = (teamName: string): boolean => {
return isTeamProcessLiteFanoutEnabled() && hasActiveProvisioningRunForTeam(teamName);
};
const noteProcessLiteStructuralReconcileSuppressedDuringLaunch = (teamName: string): void => {
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: 'skipped',
reason: 'event:process-lite:structural-reconcile:suppressed-during-launch',
operation: 'refreshTeamData',
visible: isTeamVisibleInAnyPane(teamName),
selected: useStore.getState().selectedTeamName === teamName,
activeTab: getFocusedVisibleTeamName() === teamName,
});
};
const shouldUseLaunchProcessRuntimeOnlyFanout = (
event: TeamChangeEvent,
isStaleRuntimeEvent: boolean
): boolean => {
return (
event.type === 'process' &&
event.detail === 'processes.json' &&
!isStaleRuntimeEvent &&
isTeamVisibleInAnyPane(event.teamName) &&
shouldSuppressProcessLiteStructuralReconcileDuringLaunch(event.teamName)
);
};
const noteLaunchProcessStructuralSuppressed = (
teamName: string,
operation: 'fetchTeams' | 'refreshTeamData'
): void => {
noteTeamRefreshFanout({
teamName,
surface: 'team-change-listener',
phase: 'skipped',
reason: 'event:process-lite:structural-suppressed-during-launch',
operation,
eventType: 'process',
selected: useStore.getState().selectedTeamName === teamName,
visible: true,
activeTab: getFocusedVisibleTeamName() === teamName,
});
};
const runProcessLiteStructuralReconcile = (teamName: string): void => {
if (shouldSuppressProcessLiteStructuralReconcileDuringLaunch(teamName)) {
noteProcessLiteStructuralReconcileSuppressedDuringLaunch(teamName);
return;
}
const current = useStore.getState();
noteTeamRefreshFanout({
teamName,
@ -1039,6 +1104,10 @@ export function initializeNotificationListeners(): () => void {
return;
}
if (shouldDeferAutomaticTeamDataRefreshDuringLaunch(teamName)) {
return;
}
const lastRelevantActivityAt = teamLastRelevantActivityAt.get(teamName) ?? 0;
const lastResolvedRefreshAt = getLastResolvedTeamDataRefreshAt(teamName) ?? 0;
const idleBaselineAt = Math.max(lastRelevantActivityAt, lastResolvedRefreshAt);
@ -1575,6 +1644,20 @@ export function initializeNotificationListeners(): () => void {
if (event.type === 'process') {
const processDecision = buildProcessFanoutDecision(event, isStaleRuntimeEvent);
recordProcessFanoutDecision(event, processDecision);
if (
isTeamProcessLiteFanoutEnabled() &&
shouldUseLaunchProcessRuntimeOnlyFanout(event, isStaleRuntimeEvent)
) {
noteLaunchProcessStructuralSuppressed(event.teamName, 'fetchTeams');
noteLaunchProcessStructuralSuppressed(event.teamName, 'refreshTeamData');
scheduleProcessLiteRuntimeRefresh(event.teamName);
cancelProcessLiteStructuralReconcile(
event.teamName,
'event:process-lite:structural-reconcile:cancelled-during-launch'
);
noteProcessLiteStructuralReconcileSuppressedDuringLaunch(event.teamName);
return;
}
if (processDecision.mode === 'process-lite' && isTeamProcessLiteFanoutEnabled()) {
noteTeamRefreshFanout({
teamName: event.teamName,

View file

@ -107,6 +107,14 @@ describe('team change throttling', () => {
selectedTeamName: null,
selectedTeamData: null,
teamDataCacheByName: {},
provisioningRuns: {},
currentProvisioningRunIdByTeam: {},
currentRuntimeRunIdByTeam: {},
ignoredProvisioningRunIds: {},
ignoredRuntimeRunIds: {},
memberSpawnStatusesByTeam: {},
memberSpawnSnapshotsByTeam: {},
teamAgentRuntimeByTeam: {},
memberActivityMetaByTeam: {},
paneLayout: {
focusedPaneId: 'p1',
@ -309,6 +317,7 @@ describe('team change throttling', () => {
const state = useStore.getState();
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
const fetchMemberSpawnStatusesSpy = vi.spyOn(state, 'fetchMemberSpawnStatuses');
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
hoisted.onTeamChangeCb?.(
{},
@ -318,6 +327,187 @@ describe('team change throttling', () => {
await vi.advanceTimersByTimeAsync(500);
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team');
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(20_000);
expect(fetchTeamsSpy).not.toHaveBeenCalled();
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
const summary = summarizeTeamRefreshFanout('my-team');
expect(summary.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
reason: 'event:process-lite:structural-reconcile:suppressed-during-launch',
operation: 'refreshTeamData',
phase: 'skipped',
}),
])
);
});
it('keeps active launch process file events runtime-only before team data hydrates', async () => {
useStore.setState({
selectedTeamName: 'my-team',
selectedTeamData: null,
currentProvisioningRunIdByTeam: { 'my-team': 'run-1' },
provisioningRuns: {
'run-1': {
runId: 'run-1',
teamName: 'my-team',
state: 'spawning',
message: 'Spawning',
startedAt: '2026-05-03T00:00:00.000Z',
updatedAt: '2026-05-03T00:00:00.000Z',
},
},
currentRuntimeRunIdByTeam: {},
} as never);
const state = useStore.getState();
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
const fetchMemberSpawnStatusesSpy = vi.spyOn(state, 'fetchMemberSpawnStatuses');
const fetchTeamAgentRuntimeSpy = vi.spyOn(state, 'fetchTeamAgentRuntime');
hoisted.onTeamChangeCb?.(
{},
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
);
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(799);
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team');
expect(fetchTeamAgentRuntimeSpy).toHaveBeenCalledWith('my-team');
expect(useStore.getState().selectedTeamData).toBeNull();
expect(useStore.getState().teamDataCacheByName['my-team']).toBeUndefined();
await vi.advanceTimersByTimeAsync(19_200);
expect(fetchTeamsSpy).not.toHaveBeenCalled();
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
const summary = summarizeTeamRefreshFanout('my-team');
expect(summary.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
reason: 'dry-run:process-lite:missing-visible-team-data',
operation: 'wouldKeepStructuralProcess',
phase: 'skipped',
}),
expect.objectContaining({
reason: 'event:process-lite:structural-suppressed-during-launch',
operation: 'refreshTeamData',
phase: 'skipped',
}),
])
);
});
it('keeps active launch process file events structural when process-lite is disabled', async () => {
window.localStorage.setItem('team:processLiteFanout', '0');
useStore.setState({
selectedTeamName: 'my-team',
selectedTeamData: null,
currentProvisioningRunIdByTeam: { 'my-team': 'run-1' },
provisioningRuns: {
'run-1': {
runId: 'run-1',
teamName: 'my-team',
state: 'spawning',
message: 'Spawning',
startedAt: '2026-05-03T00:00:00.000Z',
updatedAt: '2026-05-03T00:00:00.000Z',
},
},
currentRuntimeRunIdByTeam: {},
} as never);
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
hoisted.onTeamChangeCb?.(
{},
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
);
await vi.advanceTimersByTimeAsync(800);
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
});
it('suppresses idle-watchdog structural refresh during active launch', async () => {
useStore.setState({
selectedTeamName: 'my-team',
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
currentProvisioningRunIdByTeam: { 'my-team': 'run-1' },
provisioningRuns: {
'run-1': {
runId: 'run-1',
teamName: 'my-team',
state: 'spawning',
message: 'Spawning',
startedAt: '2026-05-03T00:00:00.000Z',
updatedAt: '2026-05-03T00:00:00.000Z',
},
},
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
} as never);
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
hoisted.onTeamChangeCb?.(
{},
{ type: 'process', teamName: 'my-team', detail: 'processes.json', runId: 'run-1' }
);
await vi.advanceTimersByTimeAsync(30_000);
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
});
it('keeps idle-watchdog structural refresh available when process-lite is disabled', async () => {
window.localStorage.setItem('team:processLiteFanout', '0');
useStore.setState({
selectedTeamName: 'my-team',
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
currentProvisioningRunIdByTeam: { 'my-team': 'run-1' },
provisioningRuns: {
'run-1': {
runId: 'run-1',
teamName: 'my-team',
state: 'spawning',
message: 'Spawning',
startedAt: '2026-05-03T00:00:00.000Z',
updatedAt: '2026-05-03T00:00:00.000Z',
},
},
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
} as never);
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
hoisted.onTeamChangeCb?.(
{},
{ type: 'lead-activity', teamName: 'my-team', detail: 'active', runId: 'run-1' }
);
await vi.advanceTimersByTimeAsync(29_999);
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
});
it('does not treat terminal or unknown provisioning states as process-lite active', async () => {
@ -356,6 +546,53 @@ describe('team change throttling', () => {
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
});
it('keeps unsafe process details structural during active launch', async () => {
useStore.setState({
selectedTeamName: 'my-team',
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
currentProvisioningRunIdByTeam: { 'my-team': 'run-1' },
provisioningRuns: {
'run-1': {
runId: 'run-1',
teamName: 'my-team',
state: 'spawning',
message: 'Spawning',
startedAt: '2026-05-03T00:00:00.000Z',
updatedAt: '2026-05-03T00:00:00.000Z',
},
},
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
} as never);
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
const fetchMemberSpawnStatusesSpy = vi.spyOn(useStore.getState(), 'fetchMemberSpawnStatuses');
hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team', detail: 'failed' });
await vi.advanceTimersByTimeAsync(500);
expect(fetchMemberSpawnStatusesSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(300);
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
const summary = summarizeTeamRefreshFanout('my-team');
expect(summary.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
reason: 'dry-run:process-lite:unsafe-process-detail',
operation: 'wouldKeepStructuralProcess',
phase: 'skipped',
}),
])
);
});
it('keeps strict process candidates on the structural path when process-lite is disabled', async () => {
window.localStorage.setItem('team:processLiteFanout', '0');
useStore.setState({
@ -438,6 +675,165 @@ describe('team change throttling', () => {
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
});
it('cancels pending process-lite structural reconcile when launch becomes active', async () => {
useStore.setState({
selectedTeamName: 'my-team',
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
} as never);
const state = useStore.getState();
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
hoisted.onTeamChangeCb?.(
{},
{ type: 'process', teamName: 'my-team', detail: 'processes.json' }
);
await vi.advanceTimersByTimeAsync(500);
useStore.setState({
currentProvisioningRunIdByTeam: { 'my-team': 'run-2' },
provisioningRuns: {
'run-2': {
runId: 'run-2',
teamName: 'my-team',
state: 'spawning',
message: 'Spawning',
startedAt: '2026-05-03T00:00:00.000Z',
updatedAt: '2026-05-03T00:00:00.000Z',
},
},
currentRuntimeRunIdByTeam: { 'my-team': 'run-2' },
} as never);
hoisted.onTeamChangeCb?.(
{},
{ type: 'process', teamName: 'my-team', detail: 'processes.json', runId: 'run-2' }
);
await vi.advanceTimersByTimeAsync(20_000);
expect(fetchTeamsSpy).not.toHaveBeenCalled();
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
const summary = summarizeTeamRefreshFanout('my-team');
expect(summary.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
reason: 'event:process-lite:structural-reconcile:cancelled-during-launch',
operation: 'refreshTeamData',
phase: 'skipped',
}),
])
);
});
it('skips pending process-lite structural reconcile if provisioning becomes active before the timer fires', async () => {
useStore.setState({
selectedTeamName: 'my-team',
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
} as never);
const state = useStore.getState();
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
hoisted.onTeamChangeCb?.(
{},
{ type: 'process', teamName: 'my-team', detail: 'processes.json', runId: 'run-1' }
);
await vi.advanceTimersByTimeAsync(500);
useStore.setState({
currentProvisioningRunIdByTeam: { 'my-team': 'run-2' },
provisioningRuns: {
'run-2': {
runId: 'run-2',
teamName: 'my-team',
state: 'spawning',
message: 'Spawning',
startedAt: '2026-05-03T00:00:00.000Z',
updatedAt: '2026-05-03T00:00:00.000Z',
},
},
currentRuntimeRunIdByTeam: { 'my-team': 'run-2' },
} as never);
await vi.advanceTimersByTimeAsync(20_000);
expect(fetchTeamsSpy).not.toHaveBeenCalled();
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
const summary = summarizeTeamRefreshFanout('my-team');
expect(summary.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
reason: 'event:process-lite:structural-reconcile:suppressed-during-launch',
operation: 'refreshTeamData',
phase: 'skipped',
}),
])
);
});
it('keeps pending process-lite structural reconcile available when process-lite is disabled before the timer fires', async () => {
useStore.setState({
selectedTeamName: 'my-team',
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
currentRuntimeRunIdByTeam: { 'my-team': 'run-1' },
} as never);
const state = useStore.getState();
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
hoisted.onTeamChangeCb?.(
{},
{ type: 'process', teamName: 'my-team', detail: 'processes.json', runId: 'run-1' }
);
await vi.advanceTimersByTimeAsync(500);
window.localStorage.setItem('team:processLiteFanout', '0');
useStore.setState({
currentProvisioningRunIdByTeam: { 'my-team': 'run-2' },
provisioningRuns: {
'run-2': {
runId: 'run-2',
teamName: 'my-team',
state: 'spawning',
message: 'Spawning',
startedAt: '2026-05-03T00:00:00.000Z',
updatedAt: '2026-05-03T00:00:00.000Z',
},
},
currentRuntimeRunIdByTeam: { 'my-team': 'run-2' },
} as never);
await vi.advanceTimersByTimeAsync(2_000);
expect(fetchTeamsSpy).toHaveBeenCalledTimes(1);
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
});
it('cancels pending process-lite reconcile when a normal structural event wins', async () => {
useStore.setState({
selectedTeamName: 'my-team',