fix(frontend): preserve project startup spawn errors

This commit is contained in:
Dhruv Sharma 2026-07-05 18:37:25 +05:30
parent 3ce3d7b0ab
commit 699469bee4
4 changed files with 138 additions and 8 deletions

View File

@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
@ -34,6 +34,7 @@ vi.mock("@tanstack/react-router", async (importOriginal) => {
import { SessionsBoard } from "../../components/SessionsBoard";
import { ShellProvider, type ShellContextValue } from "../../lib/shell-context";
import { useUiStore } from "../../stores/ui-store";
type Project = { id: string; name: string; path: string };
type Session = Record<string, unknown>;
@ -60,6 +61,18 @@ const workerSession: Session = {
prs: [],
};
const orchestratorSession: Session = {
id: "proj-1-orchestrator",
projectId: "proj-1",
displayName: "orchestrator",
harness: "claude-code",
kind: "orchestrator",
status: "working",
isTerminated: false,
updatedAt: "2026-07-04T10:00:00Z",
prs: [],
};
const createProjectMock = vi.fn().mockResolvedValue(undefined);
// Kept from the latest renderBoard call so tests can rerender with the same
@ -86,6 +99,11 @@ const columnCount = () => document.querySelectorAll("section").length;
beforeEach(() => {
vi.clearAllMocks();
createProjectMock.mockResolvedValue(undefined);
useUiStore.setState({
orchestratorReplacementErrors: {},
orchestratorStartupErrors: {},
restartingProjectIds: new Set(),
});
});
describe("global board first launch", () => {
@ -156,6 +174,81 @@ describe("project board with no sessions", () => {
expect(await screen.findByText(/branch is already checked out/)).toBeInTheDocument();
});
it("shows the project creation startup error after navigating to the project board", async () => {
respondWith([project], []);
useUiStore
.getState()
.setOrchestratorStartupError(
"proj-1",
"Project added, but orchestrator did not start: branch is already checked out in another worktree",
);
renderBoard(<SessionsBoard projectId="proj-1" />);
expect(await screen.findByText(/Project added, but orchestrator did not start/)).toBeInTheDocument();
expect(screen.getByText(/branch is already checked out/)).toBeInTheDocument();
});
it("clears the project creation startup error when retrying orchestrator spawn", async () => {
respondWith([project], []);
useUiStore
.getState()
.setOrchestratorStartupError(
"proj-1",
"Project added, but orchestrator did not start: branch is already checked out in another worktree",
);
spawnOrchestratorMock.mockResolvedValue("proj-1-orchestrator");
renderBoard(<SessionsBoard projectId="proj-1" />);
await screen.findByText(/Project added, but orchestrator did not start/);
const [spawnButton] = screen.getAllByRole("button", { name: "Spawn Orchestrator" });
await userEvent.click(spawnButton);
await waitFor(() =>
expect(screen.queryByText(/Project added, but orchestrator did not start/)).not.toBeInTheDocument(),
);
expect(useUiStore.getState().orchestratorStartupErrors["proj-1"]).toBeUndefined();
});
it("clears a project creation startup error when switching projects", async () => {
const otherProject: Project = { id: "proj-2", name: "other-app", path: "/repo/other-app" };
respondWith([project, otherProject], []);
useUiStore
.getState()
.setOrchestratorStartupError(
"proj-1",
"Project added, but orchestrator did not start: branch is already checked out in another worktree",
);
const { rerender } = renderBoard(<SessionsBoard projectId="proj-1" />);
await screen.findByText(/Project added, but orchestrator did not start/);
rerender(
<QueryClientProvider client={lastQueryClient!}>
<ShellProvider value={lastShell!}>
<SessionsBoard projectId="proj-2" />
</ShellProvider>
</QueryClientProvider>,
);
await screen.findByText("No sessions yet");
await waitFor(() => expect(useUiStore.getState().orchestratorStartupErrors["proj-1"]).toBeUndefined());
expect(screen.queryByText(/Project added, but orchestrator did not start/)).not.toBeInTheDocument();
});
it("clears a project creation startup error once an orchestrator exists", async () => {
respondWith([project], [orchestratorSession]);
useUiStore
.getState()
.setOrchestratorStartupError(
"proj-1",
"Project added, but orchestrator did not start: branch is already checked out in another worktree",
);
renderBoard(<SessionsBoard projectId="proj-1" />);
await screen.findByText("No sessions yet");
await waitFor(() => expect(useUiStore.getState().orchestratorStartupErrors["proj-1"]).toBeUndefined());
expect(screen.queryByText(/Project added, but orchestrator did not start/)).not.toBeInTheDocument();
});
it("clears a stale spawn error when switching projects", async () => {
const otherProject: Project = { id: "proj-2", name: "other-app", path: "/repo/other-app" };
respondWith([project, otherProject], []);

View File

@ -1,4 +1,4 @@
import { useEffect, useState, type KeyboardEvent } from "react";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { AlertTriangle, Plus, RotateCw } from "lucide-react";
@ -88,13 +88,31 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
const [isSpawning, setIsSpawning] = useState(false);
const [spawnError, setSpawnError] = useState<string | null>(null);
const restartingProjectIds = useUiStore((state) => state.restartingProjectIds);
const orchestratorStartupError = useUiStore((state) =>
projectId ? (state.orchestratorStartupErrors[projectId] ?? null) : null,
);
const setProjectRestarting = useUiStore((state) => state.setProjectRestarting);
const setOrchestratorReplacementError = useUiStore((state) => state.setOrchestratorReplacementError);
const setOrchestratorStartupError = useUiStore((state) => state.setOrchestratorStartupError);
const isProjectRestarting = projectId ? restartingProjectIds.has(projectId) : false;
const health = workspace ? orchestratorHealth(workspace, isProjectRestarting) : { state: "ok" as const };
const visibleSpawnError = spawnError ?? orchestratorStartupError;
// The board instance survives project-to-project navigation (same route,
// new param), so a spawn failure must not follow the user to another board.
useEffect(() => setSpawnError(null), [projectId]);
const previousProjectIdRef = useRef(projectId);
useEffect(() => {
const previousProjectId = previousProjectIdRef.current;
if (previousProjectId && previousProjectId !== projectId) {
setOrchestratorStartupError(previousProjectId, null);
}
previousProjectIdRef.current = projectId;
}, [projectId, setOrchestratorStartupError]);
useEffect(() => {
if (projectId && orchestrator && orchestratorStartupError) {
setOrchestratorStartupError(projectId, null);
}
}, [orchestrator, orchestratorStartupError, projectId, setOrchestratorStartupError]);
const byZone = new Map<AttentionZone, WorkspaceSession[]>();
for (const session of sessions) {
@ -129,10 +147,12 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
return;
}
setSpawnError(null);
setOrchestratorStartupError(projectId, null);
setIsSpawning(true);
try {
const sessionId = await spawnOrchestrator(projectId);
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
setOrchestratorStartupError(projectId, null);
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: { projectId, sessionId },
@ -169,9 +189,9 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
const actions = projectId ? (
<>
{spawnError && !showProjectEmpty && (
<span className="dashboard-app-header__kill-error max-w-[320px] truncate" title={spawnError}>
{spawnError}
{visibleSpawnError && !showProjectEmpty && (
<span className="dashboard-app-header__kill-error max-w-[320px] truncate" title={visibleSpawnError}>
{visibleSpawnError}
</span>
)}
<button
@ -240,7 +260,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
isProjectRestarting={isProjectRestarting}
onNewTask={() => setIsNewTaskOpen(true)}
onOpenOrchestrator={() => void openOrchestrator()}
spawnError={spawnError}
spawnError={visibleSpawnError}
/>
) : (
<div className="grid h-full grid-cols-4 gap-2">

View File

@ -54,6 +54,7 @@ function ShellLayout() {
const setProjectRestarting = useUiStore((state) => state.setProjectRestarting);
const orchestratorReplacementErrors = useUiStore((state) => state.orchestratorReplacementErrors);
const setOrchestratorReplacementError = useUiStore((state) => state.setOrchestratorReplacementError);
const setOrchestratorStartupError = useUiStore((state) => state.setOrchestratorStartupError);
const replacementErrorProjectId = Object.keys(orchestratorReplacementErrors)[0] ?? null;
const updateWorkspaces = useCallback(
@ -111,6 +112,7 @@ function ShellLayout() {
};
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
updateWorkspaces((current) => [workspace, ...current.filter((item) => item.id !== workspace.id)]);
setOrchestratorStartupError(workspace.id, null);
try {
const sessionId = await spawnOrchestrator(workspace.id);
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
@ -121,10 +123,12 @@ function ShellLayout() {
} catch (spawnError) {
void navigate({ to: "/projects/$projectId", params: { projectId: workspace.id } });
const message = spawnError instanceof Error ? spawnError.message : "Could not start orchestrator";
throw new Error(`Project added, but orchestrator did not start: ${message}`);
const startupMessage = `Project added, but orchestrator did not start: ${message}`;
setOrchestratorStartupError(workspace.id, startupMessage);
throw new Error(startupMessage);
}
},
[navigate, queryClient, updateWorkspaces],
[navigate, queryClient, setOrchestratorStartupError, updateWorkspaces],
);
const removeProject = useCallback(

View File

@ -15,6 +15,7 @@ type UiState = {
theme: Theme;
restartingProjectIds: ReadonlySet<string>;
orchestratorReplacementErrors: Record<string, string>;
orchestratorStartupErrors: Record<string, string>;
setWorkbenchTab: (tab: WorkbenchTab) => void;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
@ -22,6 +23,7 @@ type UiState = {
toggleInspector: () => void;
setProjectRestarting: (projectId: string, restarting: boolean) => void;
setOrchestratorReplacementError: (projectId: string, message: string | null) => void;
setOrchestratorStartupError: (projectId: string, message: string | null) => void;
};
const sidebarStorageKey = "ao.sidebar.open";
@ -64,6 +66,7 @@ export const useUiStore = create<UiState>((set) => ({
theme: initialTheme(),
restartingProjectIds: new Set<string>(),
orchestratorReplacementErrors: {},
orchestratorStartupErrors: {},
setWorkbenchTab: (workbenchTab) => set({ workbenchTab }),
setTheme: (theme) => {
getLocalStorage()?.setItem(themeStorageKey, theme);
@ -107,4 +110,14 @@ export const useUiStore = create<UiState>((set) => ({
}
return { orchestratorReplacementErrors };
}),
setOrchestratorStartupError: (projectId, message) =>
set((state) => {
const orchestratorStartupErrors = { ...state.orchestratorStartupErrors };
if (message) {
orchestratorStartupErrors[projectId] = message;
} else {
delete orchestratorStartupErrors[projectId];
}
return { orchestratorStartupErrors };
}),
}));