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 = (
|
const isProvisioningProgressActiveForProcessLite = (
|
||||||
progress: Pick<TeamProvisioningProgress, 'state'> | null
|
progress: Pick<TeamProvisioningProgress, 'state'> | null
|
||||||
): boolean => progress != null && ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE.has(progress.state);
|
): 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 = (
|
const addPendingGlobalRefreshDiagnostic = (
|
||||||
pending: Map<string, Set<string>>,
|
pending: Map<string, Set<string>>,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -648,6 +656,9 @@ export function initializeNotificationListeners(): () => void {
|
||||||
for (const teamName of visibleTeamNames) {
|
for (const teamName of visibleTeamNames) {
|
||||||
const teamData = selectTeamDataForName(state, teamName);
|
const teamData = selectTeamDataForName(state, teamName);
|
||||||
if (teamData?.teamName !== teamName) {
|
if (teamData?.teamName !== teamName) {
|
||||||
|
if (shouldDeferAutomaticTeamDataRefreshDuringLaunch(teamName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!isTeamDataRefreshPending(teamName)) {
|
if (!isTeamDataRefreshPending(teamName)) {
|
||||||
void state.refreshTeamData(teamName, { withDedup: true });
|
void state.refreshTeamData(teamName, { withDedup: true });
|
||||||
}
|
}
|
||||||
|
|
@ -679,6 +690,9 @@ export function initializeNotificationListeners(): () => void {
|
||||||
|
|
||||||
const currentTeamData = selectTeamDataForName(current, teamName);
|
const currentTeamData = selectTeamDataForName(current, teamName);
|
||||||
if (currentTeamData?.teamName !== teamName) {
|
if (currentTeamData?.teamName !== teamName) {
|
||||||
|
if (shouldDeferAutomaticTeamDataRefreshDuringLaunch(teamName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!isTeamDataRefreshPending(teamName)) {
|
if (!isTeamDataRefreshPending(teamName)) {
|
||||||
void current.refreshTeamData(teamName, { withDedup: true });
|
void current.refreshTeamData(teamName, { withDedup: true });
|
||||||
}
|
}
|
||||||
|
|
@ -917,7 +931,10 @@ export function initializeNotificationListeners(): () => void {
|
||||||
activeTab: getFocusedVisibleTeamName() === event.teamName,
|
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);
|
const existing = processLiteStructuralReconcileTimers.get(teamName);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -928,11 +945,59 @@ export function initializeNotificationListeners(): () => void {
|
||||||
teamName,
|
teamName,
|
||||||
surface: 'team-change-listener',
|
surface: 'team-change-listener',
|
||||||
phase: 'skipped',
|
phase: 'skipped',
|
||||||
reason: 'event:process-lite:structural-reconcile:cancelled-by-structural',
|
reason,
|
||||||
operation: 'refreshTeamData',
|
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 => {
|
const runProcessLiteStructuralReconcile = (teamName: string): void => {
|
||||||
|
if (shouldSuppressProcessLiteStructuralReconcileDuringLaunch(teamName)) {
|
||||||
|
noteProcessLiteStructuralReconcileSuppressedDuringLaunch(teamName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const current = useStore.getState();
|
const current = useStore.getState();
|
||||||
noteTeamRefreshFanout({
|
noteTeamRefreshFanout({
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -1039,6 +1104,10 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldDeferAutomaticTeamDataRefreshDuringLaunch(teamName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const lastRelevantActivityAt = teamLastRelevantActivityAt.get(teamName) ?? 0;
|
const lastRelevantActivityAt = teamLastRelevantActivityAt.get(teamName) ?? 0;
|
||||||
const lastResolvedRefreshAt = getLastResolvedTeamDataRefreshAt(teamName) ?? 0;
|
const lastResolvedRefreshAt = getLastResolvedTeamDataRefreshAt(teamName) ?? 0;
|
||||||
const idleBaselineAt = Math.max(lastRelevantActivityAt, lastResolvedRefreshAt);
|
const idleBaselineAt = Math.max(lastRelevantActivityAt, lastResolvedRefreshAt);
|
||||||
|
|
@ -1575,6 +1644,20 @@ export function initializeNotificationListeners(): () => void {
|
||||||
if (event.type === 'process') {
|
if (event.type === 'process') {
|
||||||
const processDecision = buildProcessFanoutDecision(event, isStaleRuntimeEvent);
|
const processDecision = buildProcessFanoutDecision(event, isStaleRuntimeEvent);
|
||||||
recordProcessFanoutDecision(event, processDecision);
|
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()) {
|
if (processDecision.mode === 'process-lite' && isTeamProcessLiteFanoutEnabled()) {
|
||||||
noteTeamRefreshFanout({
|
noteTeamRefreshFanout({
|
||||||
teamName: event.teamName,
|
teamName: event.teamName,
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,14 @@ describe('team change throttling', () => {
|
||||||
selectedTeamName: null,
|
selectedTeamName: null,
|
||||||
selectedTeamData: null,
|
selectedTeamData: null,
|
||||||
teamDataCacheByName: {},
|
teamDataCacheByName: {},
|
||||||
|
provisioningRuns: {},
|
||||||
|
currentProvisioningRunIdByTeam: {},
|
||||||
|
currentRuntimeRunIdByTeam: {},
|
||||||
|
ignoredProvisioningRunIds: {},
|
||||||
|
ignoredRuntimeRunIds: {},
|
||||||
|
memberSpawnStatusesByTeam: {},
|
||||||
|
memberSpawnSnapshotsByTeam: {},
|
||||||
|
teamAgentRuntimeByTeam: {},
|
||||||
memberActivityMetaByTeam: {},
|
memberActivityMetaByTeam: {},
|
||||||
paneLayout: {
|
paneLayout: {
|
||||||
focusedPaneId: 'p1',
|
focusedPaneId: 'p1',
|
||||||
|
|
@ -309,6 +317,7 @@ describe('team change throttling', () => {
|
||||||
const state = useStore.getState();
|
const state = useStore.getState();
|
||||||
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
||||||
const fetchMemberSpawnStatusesSpy = vi.spyOn(state, 'fetchMemberSpawnStatuses');
|
const fetchMemberSpawnStatusesSpy = vi.spyOn(state, 'fetchMemberSpawnStatuses');
|
||||||
|
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
||||||
|
|
||||||
hoisted.onTeamChangeCb?.(
|
hoisted.onTeamChangeCb?.(
|
||||||
{},
|
{},
|
||||||
|
|
@ -318,6 +327,187 @@ describe('team change throttling', () => {
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team');
|
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team');
|
||||||
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
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 () => {
|
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 });
|
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 () => {
|
it('keeps strict process candidates on the structural path when process-lite is disabled', async () => {
|
||||||
window.localStorage.setItem('team:processLiteFanout', '0');
|
window.localStorage.setItem('team:processLiteFanout', '0');
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
|
|
@ -438,6 +675,165 @@ describe('team change throttling', () => {
|
||||||
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('cancels pending process-lite reconcile when a normal structural event wins', async () => {
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
selectedTeamName: 'my-team',
|
selectedTeamName: 'my-team',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue