diff --git a/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx b/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx index d151c40b65..e06fad63ba 100644 --- a/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx +++ b/frontend/src/renderer/__tests__/integration/pr-hydration.test.tsx @@ -22,6 +22,7 @@ vi.mock("@tanstack/react-router", async (importOriginal) => { import { SessionsBoard } from "../../components/SessionsBoard"; import { PullRequestsPage } from "../../components/PullRequestsPage"; +import { ShellProvider } from "../../lib/shell-context"; // One ordinary project with one worker session that has multiple PRs. function respondWithProjectAndPRs() { @@ -75,7 +76,13 @@ function respondWithProjectAndPRs() { function renderWithProviders(node: ReactNode) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render({node}); + render( + + + {node} + + , + ); } beforeEach(() => { diff --git a/frontend/src/renderer/components/SessionsBoard.test.tsx b/frontend/src/renderer/components/SessionsBoard.test.tsx new file mode 100644 index 0000000000..00129d3898 --- /dev/null +++ b/frontend/src/renderer/components/SessionsBoard.test.tsx @@ -0,0 +1,88 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ShellProvider } from "../lib/shell-context"; +import { SessionsBoard } from "./SessionsBoard"; + +const { getMock, postMock, navigateMock } = vi.hoisted(() => ({ + getMock: vi.fn(), + postMock: vi.fn(), + navigateMock: vi.fn(), +})); + +vi.mock("../lib/api-client", () => ({ + apiClient: { GET: getMock, POST: postMock }, + apiErrorMessage: (e: unknown) => (e instanceof Error ? e.message : "error"), + hasTrustedApiBaseUrl: () => true, + setApiBaseUrl: vi.fn(), +})); + +vi.mock("@tanstack/react-router", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => navigateMock }; +}); + +function mockWorkspace() { + getMock.mockImplementation(async (url: string) => { + if (url === "/api/v1/projects") { + return { data: { projects: [{ id: "proj-1", name: "my-app", path: "/repo/my-app" }] }, error: undefined }; + } + if (url === "/api/v1/sessions") { + return { data: { sessions: [] }, error: undefined }; + } + throw new Error(`unexpected GET ${url}`); + }); +} + +function renderBoard(status: { state: "ready" | "stopped"; port?: number; message?: string }) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + , + ); +} + +beforeEach(() => { + getMock.mockReset(); + postMock.mockReset(); + navigateMock.mockReset(); + mockWorkspace(); + window.ao!.daemon.start = vi.fn().mockResolvedValue({ state: "ready", port: 4567 }); +}); + +describe("SessionsBoard daemon recovery", () => { + it("disables daemon-backed project actions while the daemon is stopped", async () => { + renderBoard({ state: "stopped" }); + + expect(await screen.findByRole("button", { name: "New task" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Spawn Orchestrator" })).toBeDisabled(); + expect( + screen.getByText("AO daemon is stopped. Restart it to create tasks or start an orchestrator."), + ).toBeInTheDocument(); + }); + + it("offers an explicit daemon restart action", async () => { + renderBoard({ state: "stopped" }); + + await userEvent.click(await screen.findByRole("button", { name: "Restart daemon" })); + + await waitFor(() => expect(window.ao!.daemon.start).toHaveBeenCalledTimes(1)); + }); + + it("surfaces spawn errors instead of leaking an unhandled rejection", async () => { + postMock.mockResolvedValue({ + data: undefined, + error: { message: "AO daemon is not ready" }, + response: { status: 503 }, + }); + renderBoard({ state: "ready", port: 4567 }); + + await userEvent.click(await screen.findByRole("button", { name: "Spawn Orchestrator" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("AO daemon is not ready"); + }); +}); diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx index 65b8e4934c..47338d0a53 100644 --- a/frontend/src/renderer/components/SessionsBoard.tsx +++ b/frontend/src/renderer/components/SessionsBoard.tsx @@ -8,8 +8,10 @@ import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery import { DashboardSubhead } from "./DashboardSubhead"; import { OrchestratorIcon } from "./icons"; import { NewTaskDialog } from "./NewTaskDialog"; +import { startDaemon } from "../lib/daemon-status"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; import { prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display"; +import { useShell } from "../lib/shell-context"; import { cn } from "../lib/utils"; import { PRAttentionPanel, PRStatusStrip } from "./PRSummaryDisplay"; @@ -67,6 +69,7 @@ const COLUMNS: Column[] = [ export function SessionsBoard({ projectId }: SessionsBoardProps) { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { daemonStatus } = useShell(); const workspaceQuery = useWorkspaceQuery(); const all = workspaceQuery.data ?? []; const workspaces = projectId ? all.filter((w) => w.id === projectId) : all; @@ -76,6 +79,9 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { : undefined; const [isNewTaskOpen, setIsNewTaskOpen] = useState(false); const [isSpawning, setIsSpawning] = useState(false); + const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); + const [actionError, setActionError] = useState(null); + const daemonReady = daemonStatus.state === "ready"; const byZone = new Map(); for (const session of sessions) { @@ -95,6 +101,10 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { const openOrchestrator = async () => { if (!projectId) return; + if (!daemonReady) { + setActionError("AO daemon is stopped. Restart it before starting sessions."); + return; + } if (orchestrator) { void navigate({ to: "/projects/$projectId/sessions/$sessionId", @@ -103,6 +113,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { return; } setIsSpawning(true); + setActionError(null); try { const sessionId = await spawnOrchestrator(projectId); await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); @@ -110,11 +121,26 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { to: "/projects/$projectId/sessions/$sessionId", params: { projectId, sessionId }, }); + } catch (error) { + setActionError(error instanceof Error ? error.message : "Could not start orchestrator"); } finally { setIsSpawning(false); } }; + const restartDaemon = async () => { + setIsRestartingDaemon(true); + setActionError(null); + try { + await startDaemon(); + await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + } catch (error) { + setActionError(error instanceof Error ? error.message : "Could not restart AO daemon"); + } finally { + setIsRestartingDaemon(false); + } + }; + const handleTaskCreated = async (sessionId: string) => { if (!projectId) return; await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); @@ -126,10 +152,25 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { const actions = projectId ? ( <> + {!daemonReady ? ( + void restartDaemon()} + type="button" + > + {isRestartingDaemon ? "Restarting..." : "Restart daemon"} + + ) : null} setIsNewTaskOpen(true)} + disabled={!daemonReady} + onClick={() => { + setActionError(null); + setIsNewTaskOpen(true); + }} type="button" > @@ -138,7 +179,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { void openOrchestrator()} type="button" > @@ -155,6 +196,17 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) { subtitle="Live agent sessions flowing from work → review → merge." actions={actions} /> + {projectId && (!daemonReady || actionError) ? ( + + {actionError ? ( + + {actionError} + + ) : ( + AO daemon is stopped. Restart it to create tasks or start an orchestrator. + )} + + ) : null} {workspaceQuery.isError ? ( diff --git a/frontend/src/renderer/components/ShellTopbar.tsx b/frontend/src/renderer/components/ShellTopbar.tsx index c336b19cb3..4961207e9f 100644 --- a/frontend/src/renderer/components/ShellTopbar.tsx +++ b/frontend/src/renderer/components/ShellTopbar.tsx @@ -13,6 +13,8 @@ import { } from "../types/workspace"; import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { apiClient, apiErrorMessage } from "../lib/api-client"; +import { startDaemon } from "../lib/daemon-status"; +import { useShell } from "../lib/shell-context"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; import { addRendererExceptionStep, captureRendererEvent, captureRendererException } from "../lib/telemetry"; import { useUiStore } from "../stores/ui-store"; @@ -52,11 +54,14 @@ const STATUS_PILL: Record state.isInspectorOpen); const toggleInspector = useUiStore((state) => state.toggleInspector); const [isSpawning, setIsSpawning] = useState(false); + const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); const [isNewTaskOpen, setIsNewTaskOpen] = useState(false); + const daemonReady = daemonStatus.state === "ready"; const all = useWorkspaceQuery().data ?? []; const session = params.sessionId @@ -79,7 +84,7 @@ export function ShellTopbar() { projectId ? void navigate({ to: "/projects/$projectId", params: { projectId } }) : void navigate({ to: "/" }); const openNewTask = () => { - if (!projectId) return; + if (!projectId || !daemonReady) return; setIsNewTaskOpen(true); }; @@ -94,6 +99,7 @@ export function ShellTopbar() { const openOrchestrator = async () => { if (!projectId) return; + if (!daemonReady) return; void addRendererExceptionStep("Orchestrator open requested", { source: "orchestrator-open", operation: "open_orchestrator", @@ -129,6 +135,24 @@ export function ShellTopbar() { } }; + const restartDaemon = async () => { + setIsRestartingDaemon(true); + try { + await startDaemon(); + await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + } catch (error) { + void captureRendererException(error, { + source: "daemon-restart", + operation: "restart_daemon", + surface: isSessionRoute ? "session_detail" : "project_board", + project_id: projectId, + }); + console.error("Failed to restart daemon:", error); + } finally { + setIsRestartingDaemon(false); + } + }; + return ( @@ -164,6 +188,18 @@ export function ShellTopbar() { + {projectId && !daemonReady ? ( + void restartDaemon()} + style={noDragStyle} + type="button" + > + {isRestartingDaemon ? "Restarting..." : "Restart daemon"} + + ) : null} {isSessionRoute ? ( <> {isOrchestrator ? ( @@ -171,6 +207,7 @@ export function ShellTopbar() { void openOrchestrator()} style={noDragStyle} type="button" diff --git a/frontend/src/renderer/lib/daemon-status.ts b/frontend/src/renderer/lib/daemon-status.ts index dcfc96ad1e..2b1578b102 100644 --- a/frontend/src/renderer/lib/daemon-status.ts +++ b/frontend/src/renderer/lib/daemon-status.ts @@ -17,6 +17,12 @@ export async function refreshDaemonStatus(): Promise { return nextStatus; } +export async function startDaemon(): Promise { + const nextStatus = await aoBridge.daemon.start(); + applyDaemonStatus(nextStatus); + return nextStatus; +} + export function readDaemonStatus(): Promise { return aoBridge.daemon.getStatus(); }
+ {actionError} +
AO daemon is stopped. Restart it to create tasks or start an orchestrator.