diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 622c601a..b19f8f9f 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -296,6 +296,14 @@ export function initializeNotificationListeners(): () => void { const isProvisioningProgressActiveForProcessLite = ( progress: Pick | 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>, 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, diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index fba2e8aa..23331506 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -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',