perf(renderer): suppress launch structural refreshes
This commit is contained in:
parent
24d96b5bec
commit
b1d27c1382
2 changed files with 481 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue