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
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
run: pnpm install --frozen-lockfile
@ -336,6 +339,9 @@ jobs:
with:
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
run: pnpm install --frozen-lockfile
@ -452,6 +458,9 @@ jobs:
with:
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
run: pnpm install --frozen-lockfile
@ -573,6 +582,9 @@ jobs:
sudo apt-get update
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
run: pnpm install --frozen-lockfile

View file

@ -927,7 +927,7 @@ let shutdownComplete = false;
const startupTimers = new Set<ReturnType<typeof setTimeout>>();
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_BACKGROUND_SERVICE_DELAY_MS = 5_000;
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
// when the centralized init chain has already started a fetch.
const repositoryGroupsLoading = useStore((s) => s.repositoryGroupsLoading);
const repositoryGroupsInitialized = useStore((s) => s.repositoryGroupsInitialized);
const repositoryGroupsError = useStore((s) => s.repositoryGroupsError);
const projectsLoading = useStore((s) => s.projectsLoading);
const projectsInitialized = useStore((s) => s.projectsInitialized);
const projectsError = useStore((s) => s.projectsError);
useEffect(() => {
if (
viewMode === 'grouped' &&
repositoryGroups.length === 0 &&
!repositoryGroupsInitialized &&
!repositoryGroupsLoading &&
!repositoryGroupsError
) {
void fetchRepositoryGroups();
} else if (viewMode === 'flat' && projects.length === 0 && !projectsLoading && !projectsError) {
} else if (viewMode === 'flat' && !projectsInitialized && !projectsLoading && !projectsError) {
void fetchProjects();
}
}, [
viewMode,
repositoryGroups.length,
projects.length,
repositoryGroupsInitialized,
projectsInitialized,
repositoryGroupsLoading,
repositoryGroupsError,
projectsLoading,

View file

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

View file

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

View file

@ -39,6 +39,7 @@ export interface RepositorySlice {
selectedRepositoryId: string | null;
selectedWorktreeId: string | null;
repositoryGroupsLoading: boolean;
repositoryGroupsInitialized: boolean;
repositoryGroupsError: string | null;
viewMode: 'flat' | 'grouped';
@ -62,6 +63,7 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
selectedRepositoryId: null,
selectedWorktreeId: null,
repositoryGroupsLoading: false,
repositoryGroupsInitialized: false,
repositoryGroupsError: null,
viewMode: 'grouped', // Default to grouped view
@ -78,7 +80,11 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
'get-repository-groups'
);
// 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;
if (ms >= 2000) {
logger.warn(`fetchRepositoryGroups slow ms=${ms} count=${groups.length}`);

View file

@ -15,6 +15,7 @@ interface StoreState {
softDeleteTask: ReturnType<typeof vi.fn>;
projects: { path: string; name: string; sessions: unknown[]; totalSessions?: number }[];
projectsLoading: boolean;
projectsInitialized: boolean;
projectsError: string | null;
viewMode: 'flat' | 'grouped';
repositoryGroups: {
@ -24,6 +25,7 @@ interface StoreState {
worktrees: { path: string }[];
}[];
repositoryGroupsLoading: boolean;
repositoryGroupsInitialized: boolean;
repositoryGroupsError: string | null;
teams: (Pick<TeamSummary, 'teamName' | 'displayName'> & Partial<TeamSummary>)[];
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.projects = [];
storeState.projectsLoading = false;
storeState.projectsInitialized = false;
storeState.projectsError = null;
storeState.viewMode = 'flat';
storeState.repositoryGroups = [];
storeState.repositoryGroupsLoading = false;
storeState.repositoryGroupsInitialized = false;
storeState.repositoryGroupsError = null;
storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }];
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 () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.globalTasks = Array.from({ length: 6 }, (_, index) => makeTask(index + 1));