fix(startup): prevent recovery regressions

This commit is contained in:
777genius 2026-05-23 17:22:52 +03:00
parent 2be35c74ec
commit 9518ce920a
7 changed files with 78 additions and 11 deletions

View file

@ -45,6 +45,9 @@ jobs:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
- name: Restore pnpm node-gyp executable bit
run: find "$(dirname "$(command -v pnpm)")/store" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@ -336,6 +339,9 @@ jobs:
with: with:
python-version: '3.11' python-version: '3.11'
- name: Restore pnpm node-gyp executable bit
run: find "$(dirname "$(command -v pnpm)")/store" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@ -452,6 +458,9 @@ jobs:
with: with:
python-version: '3.11' python-version: '3.11'
- name: Restore pnpm node-gyp executable bit
run: find "$(dirname "$(command -v pnpm)")/store" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@ -573,6 +582,9 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y libarchive-tools rpm xvfb sudo apt-get install -y libarchive-tools rpm xvfb
- name: Restore pnpm node-gyp executable bit
run: find "$(dirname "$(command -v pnpm)")/store" -path '*/node-gyp/gyp/gyp_main.py' -exec chmod +x {} \; 2>/dev/null || true
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile

View file

@ -927,7 +927,7 @@ let shutdownComplete = false;
const startupTimers = new Set<ReturnType<typeof setTimeout>>(); const startupTimers = new Set<ReturnType<typeof setTimeout>>();
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000; const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
const STARTUP_RECOVERY_DELAY_MS = 60_000; const STARTUP_RECOVERY_DELAY_MS = 10_000;
const STARTUP_CLI_WARMUP_DELAY_MS = 90_000; const STARTUP_CLI_WARMUP_DELAY_MS = 90_000;
const STARTUP_BACKGROUND_SERVICE_DELAY_MS = 5_000; const STARTUP_BACKGROUND_SERVICE_DELAY_MS = 5_000;
const STARTUP_RECOVERY_CONCURRENCY = 1; const STARTUP_RECOVERY_CONCURRENCY = 1;

View file

@ -274,24 +274,26 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
// Loading guards in the store actions prevent duplicate IPC calls // Loading guards in the store actions prevent duplicate IPC calls
// when the centralized init chain has already started a fetch. // when the centralized init chain has already started a fetch.
const repositoryGroupsLoading = useStore((s) => s.repositoryGroupsLoading); const repositoryGroupsLoading = useStore((s) => s.repositoryGroupsLoading);
const repositoryGroupsInitialized = useStore((s) => s.repositoryGroupsInitialized);
const repositoryGroupsError = useStore((s) => s.repositoryGroupsError); const repositoryGroupsError = useStore((s) => s.repositoryGroupsError);
const projectsLoading = useStore((s) => s.projectsLoading); const projectsLoading = useStore((s) => s.projectsLoading);
const projectsInitialized = useStore((s) => s.projectsInitialized);
const projectsError = useStore((s) => s.projectsError); const projectsError = useStore((s) => s.projectsError);
useEffect(() => { useEffect(() => {
if ( if (
viewMode === 'grouped' && viewMode === 'grouped' &&
repositoryGroups.length === 0 && !repositoryGroupsInitialized &&
!repositoryGroupsLoading && !repositoryGroupsLoading &&
!repositoryGroupsError !repositoryGroupsError
) { ) {
void fetchRepositoryGroups(); void fetchRepositoryGroups();
} else if (viewMode === 'flat' && projects.length === 0 && !projectsLoading && !projectsError) { } else if (viewMode === 'flat' && !projectsInitialized && !projectsLoading && !projectsError) {
void fetchProjects(); void fetchProjects();
} }
}, [ }, [
viewMode, viewMode,
repositoryGroups.length, repositoryGroupsInitialized,
projects.length, projectsInitialized,
repositoryGroupsLoading, repositoryGroupsLoading,
repositoryGroupsError, repositoryGroupsError,
projectsLoading, projectsLoading,

View file

@ -206,10 +206,12 @@ export const GlobalTaskList = memo(function GlobalTaskList({
softDeleteTask, softDeleteTask,
projects, projects,
projectsLoading, projectsLoading,
projectsInitialized,
projectsError, projectsError,
viewMode, viewMode,
repositoryGroups, repositoryGroups,
repositoryGroupsLoading, repositoryGroupsLoading,
repositoryGroupsInitialized,
repositoryGroupsError, repositoryGroupsError,
teams, teams,
provisioningRuns, provisioningRuns,
@ -226,10 +228,12 @@ export const GlobalTaskList = memo(function GlobalTaskList({
softDeleteTask: s.softDeleteTask, softDeleteTask: s.softDeleteTask,
projects: s.projects, projects: s.projects,
projectsLoading: s.projectsLoading, projectsLoading: s.projectsLoading,
projectsInitialized: s.projectsInitialized,
projectsError: s.projectsError, projectsError: s.projectsError,
viewMode: s.viewMode, viewMode: s.viewMode,
repositoryGroups: s.repositoryGroups, repositoryGroups: s.repositoryGroups,
repositoryGroupsLoading: s.repositoryGroupsLoading, repositoryGroupsLoading: s.repositoryGroupsLoading,
repositoryGroupsInitialized: s.repositoryGroupsInitialized,
repositoryGroupsError: s.repositoryGroupsError, repositoryGroupsError: s.repositoryGroupsError,
teams: s.teams, teams: s.teams,
provisioningRuns: s.provisioningRuns, provisioningRuns: s.provisioningRuns,
@ -457,22 +461,22 @@ export const GlobalTaskList = memo(function GlobalTaskList({
useEffect(() => { useEffect(() => {
if ( if (
viewMode === 'grouped' && viewMode === 'grouped' &&
repositoryGroups.length === 0 && !repositoryGroupsInitialized &&
!repositoryGroupsLoading && !repositoryGroupsLoading &&
!repositoryGroupsError !repositoryGroupsError
) { ) {
void fetchRepositoryGroups(); void fetchRepositoryGroups();
} else if (viewMode === 'flat' && projects.length === 0 && !projectsLoading && !projectsError) { } else if (viewMode === 'flat' && !projectsInitialized && !projectsLoading && !projectsError) {
void fetchProjects(); void fetchProjects();
} }
}, [ }, [
fetchProjects, fetchProjects,
fetchRepositoryGroups, fetchRepositoryGroups,
projects.length,
projectsError, projectsError,
projectsInitialized,
projectsLoading, projectsLoading,
repositoryGroups.length,
repositoryGroupsError, repositoryGroupsError,
repositoryGroupsInitialized,
repositoryGroupsLoading, repositoryGroupsLoading,
viewMode, viewMode,
]); ]);

View file

@ -19,6 +19,7 @@ export interface ProjectSlice {
projects: Project[]; projects: Project[];
selectedProjectId: string | null; selectedProjectId: string | null;
projectsLoading: boolean; projectsLoading: boolean;
projectsInitialized: boolean;
projectsError: string | null; projectsError: string | null;
// Actions // Actions
@ -35,6 +36,7 @@ export const createProjectSlice: StateCreator<AppState, [], [], ProjectSlice> =
projects: [], projects: [],
selectedProjectId: null, selectedProjectId: null,
projectsLoading: false, projectsLoading: false,
projectsInitialized: false,
projectsError: null, projectsError: null,
// Fetch all projects from main process // Fetch all projects from main process
@ -48,7 +50,7 @@ export const createProjectSlice: StateCreator<AppState, [], [], ProjectSlice> =
const sorted = [...projects].sort( const sorted = [...projects].sort(
(a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0) (a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0)
); );
set({ projects: sorted, projectsLoading: false }); set({ projects: sorted, projectsLoading: false, projectsInitialized: true });
} catch (error) { } catch (error) {
set({ set({
projectsError: error instanceof Error ? error.message : 'Failed to fetch projects', projectsError: error instanceof Error ? error.message : 'Failed to fetch projects',

View file

@ -39,6 +39,7 @@ export interface RepositorySlice {
selectedRepositoryId: string | null; selectedRepositoryId: string | null;
selectedWorktreeId: string | null; selectedWorktreeId: string | null;
repositoryGroupsLoading: boolean; repositoryGroupsLoading: boolean;
repositoryGroupsInitialized: boolean;
repositoryGroupsError: string | null; repositoryGroupsError: string | null;
viewMode: 'flat' | 'grouped'; viewMode: 'flat' | 'grouped';
@ -62,6 +63,7 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
selectedRepositoryId: null, selectedRepositoryId: null,
selectedWorktreeId: null, selectedWorktreeId: null,
repositoryGroupsLoading: false, repositoryGroupsLoading: false,
repositoryGroupsInitialized: false,
repositoryGroupsError: null, repositoryGroupsError: null,
viewMode: 'grouped', // Default to grouped view viewMode: 'grouped', // Default to grouped view
@ -78,7 +80,11 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
'get-repository-groups' 'get-repository-groups'
); );
// Already sorted by most recent session in the scanner // Already sorted by most recent session in the scanner
set({ repositoryGroups: groups, repositoryGroupsLoading: false }); set({
repositoryGroups: groups,
repositoryGroupsLoading: false,
repositoryGroupsInitialized: true,
});
const ms = Date.now() - startedAt; const ms = Date.now() - startedAt;
if (ms >= 2000) { if (ms >= 2000) {
logger.warn(`fetchRepositoryGroups slow ms=${ms} count=${groups.length}`); logger.warn(`fetchRepositoryGroups slow ms=${ms} count=${groups.length}`);

View file

@ -15,6 +15,7 @@ interface StoreState {
softDeleteTask: ReturnType<typeof vi.fn>; softDeleteTask: ReturnType<typeof vi.fn>;
projects: { path: string; name: string; sessions: unknown[]; totalSessions?: number }[]; projects: { path: string; name: string; sessions: unknown[]; totalSessions?: number }[];
projectsLoading: boolean; projectsLoading: boolean;
projectsInitialized: boolean;
projectsError: string | null; projectsError: string | null;
viewMode: 'flat' | 'grouped'; viewMode: 'flat' | 'grouped';
repositoryGroups: { repositoryGroups: {
@ -24,6 +25,7 @@ interface StoreState {
worktrees: { path: string }[]; worktrees: { path: string }[];
}[]; }[];
repositoryGroupsLoading: boolean; repositoryGroupsLoading: boolean;
repositoryGroupsInitialized: boolean;
repositoryGroupsError: string | null; repositoryGroupsError: string | null;
teams: (Pick<TeamSummary, 'teamName' | 'displayName'> & Partial<TeamSummary>)[]; teams: (Pick<TeamSummary, 'teamName' | 'displayName'> & Partial<TeamSummary>)[];
provisioningRuns: Record<string, { state: string; runId: string; updatedAt: string }>; provisioningRuns: Record<string, { state: string; runId: string; updatedAt: string }>;
@ -216,10 +218,12 @@ describe('GlobalTaskList project grouping', () => {
storeState.softDeleteTask = vi.fn(() => Promise.resolve(undefined)); storeState.softDeleteTask = vi.fn(() => Promise.resolve(undefined));
storeState.projects = []; storeState.projects = [];
storeState.projectsLoading = false; storeState.projectsLoading = false;
storeState.projectsInitialized = false;
storeState.projectsError = null; storeState.projectsError = null;
storeState.viewMode = 'flat'; storeState.viewMode = 'flat';
storeState.repositoryGroups = []; storeState.repositoryGroups = [];
storeState.repositoryGroupsLoading = false; storeState.repositoryGroupsLoading = false;
storeState.repositoryGroupsInitialized = false;
storeState.repositoryGroupsError = null; storeState.repositoryGroupsError = null;
storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }]; storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }];
storeState.provisioningRuns = {}; storeState.provisioningRuns = {};
@ -310,6 +314,43 @@ describe('GlobalTaskList project grouping', () => {
}); });
}); });
it('does not refetch repository groups after an empty grouped result is initialized', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.viewMode = 'grouped';
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(GlobalTaskList));
await flushMicrotasks();
});
expect(storeState.fetchRepositoryGroups).toHaveBeenCalledTimes(1);
storeState.repositoryGroupsLoading = true;
await act(async () => {
notifyStoreUpdate();
await flushMicrotasks();
});
storeState.repositoryGroupsLoading = false;
storeState.repositoryGroupsInitialized = true;
storeState.repositoryGroups = [];
await act(async () => {
notifyStoreUpdate();
await flushMicrotasks();
});
expect(storeState.fetchRepositoryGroups).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('shows five tasks first, then expands and collapses with Show more and Show less', async () => { it('shows five tasks first, then expands and collapses with Show more and Show less', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.globalTasks = Array.from({ length: 6 }, (_, index) => makeTask(index + 1)); storeState.globalTasks = Array.from({ length: 6 }, (_, index) => makeTask(index + 1));