+ {projectId && health.state !== "ok" ? (
+
+
+
{health.message}
+ {health.state === "restart_needed" || health.state === "duplicates" ? (
+
+ ) : null}
+
+ ) : null}
{workspaceQuery.isError ? (
Could not load sessions.
) : (
diff --git a/frontend/src/renderer/components/ShellTopbar.tsx b/frontend/src/renderer/components/ShellTopbar.tsx
index c336b19cb..2929475c7 100644
--- a/frontend/src/renderer/components/ShellTopbar.tsx
+++ b/frontend/src/renderer/components/ShellTopbar.tsx
@@ -55,6 +55,7 @@ export function ShellTopbar() {
const params = useParams({ strict: false }) as { projectId?: string; sessionId?: string };
const isInspectorOpen = useUiStore((state) => state.isInspectorOpen);
const toggleInspector = useUiStore((state) => state.toggleInspector);
+ const restartingProjectIds = useUiStore((state) => state.restartingProjectIds);
const [isSpawning, setIsSpawning] = useState(false);
const [isNewTaskOpen, setIsNewTaskOpen] = useState(false);
const all = useWorkspaceQuery().data ?? [];
@@ -74,17 +75,18 @@ export function ShellTopbar() {
const project = projectId ? all.find((workspace) => workspace.id === projectId) : undefined;
const projectLabel = project?.name ?? session?.workspaceName ?? (projectId ? "" : "agent-orchestrator");
const orchestrator = projectId ? findProjectOrchestrator(all, projectId) : undefined;
+ const isProjectRestarting = projectId ? restartingProjectIds.has(projectId) : false;
const openBoard = () =>
projectId ? void navigate({ to: "/projects/$projectId", params: { projectId } }) : void navigate({ to: "/" });
const openNewTask = () => {
- if (!projectId) return;
+ if (!projectId || isProjectRestarting) return;
setIsNewTaskOpen(true);
};
const handleTaskCreated = async (sessionId: string) => {
- if (!projectId) return;
+ if (!projectId || isProjectRestarting) return;
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
void navigate({
to: "/projects/$projectId/sessions/$sessionId",
@@ -171,6 +173,7 @@ export function ShellTopbar() {
)}
{/* Inspector collapse (worker sessions only — orchestrators have no rail). */}
diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx
index e1200349f..09923768c 100644
--- a/frontend/src/renderer/components/Sidebar.tsx
+++ b/frontend/src/renderer/components/Sidebar.tsx
@@ -16,7 +16,7 @@ import {
import { useRef, useState, type ReactNode } from "react";
import {
attentionZone,
- isOrchestratorSession,
+ newestActiveOrchestrator,
sessionIsActive,
type WorkspaceSession,
type WorkspaceSummary,
@@ -426,16 +426,19 @@ function ProjectItem({
const [removeError, setRemoveError] = useState
(null);
const [isRemoving, setIsRemoving] = useState(false);
const [isSpawning, setIsSpawning] = useState(false);
+ const restartingProjectIds = useUiStore((state) => state.restartingProjectIds);
+ const isProjectRestarting = restartingProjectIds.has(workspace.id);
// Live workers only: merged/terminated sessions leave the sidebar and stay
// reachable through the board's Done / Terminated bar (SessionsBoard).
const sessions = workerSessions(workspace.sessions).filter(sessionIsActive);
// The project's live orchestrator (if any) backs the hover Orchestrator
// button: navigate to it when present, otherwise spawn one first.
- const orchestrator = workspace.sessions.find((s) => isOrchestratorSession(s) && sessionIsActive(s));
+ const orchestrator = newestActiveOrchestrator(workspace.sessions);
// Mirrors ShellTopbar's launcher: attach to the running orchestrator, or
// spawn one via the daemon and follow it once the workspace refetches.
const openOrchestrator = async () => {
+ if (isProjectRestarting) return;
if (orchestrator) {
selection.goSession(workspace.id, orchestrator.id);
return;
@@ -546,7 +549,7 @@ function ProjectItem({
- {isSpawning ? "Spawning…" : orchestrator ? "Orchestrator" : "Spawn orchestrator"}
+ {isProjectRestarting ? "Restarting…" : isSpawning ? "Spawning…" : orchestrator ? "Orchestrator" : "Spawn orchestrator"}
diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx
index 21b402872..60fc3c4e8 100644
--- a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx
+++ b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx
@@ -51,7 +51,16 @@ describe("useWorkspaceQuery", () => {
it("maps projects and their sessions, applying provider/status/title fallbacks", async () => {
respondWith({
projects: {
- data: { projects: [{ id: "proj-1", name: "my-app", path: "/home/me/my-app" }] },
+ data: {
+ projects: [
+ {
+ id: "proj-1",
+ name: "my-app",
+ path: "/home/me/my-app",
+ orchestratorAgent: "codex",
+ },
+ ],
+ },
error: undefined,
},
sessions: {
@@ -92,7 +101,7 @@ describe("useWorkspaceQuery", () => {
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const [workspace] = result.current.data ?? [];
- expect(workspace).toMatchObject({ id: "proj-1", name: "my-app", path: "/home/me/my-app" });
+ expect(workspace).toMatchObject({ id: "proj-1", name: "my-app", path: "/home/me/my-app", orchestratorAgent: "codex" });
expect(workspace.sessions).toHaveLength(2);
expect(workspace.sessions[0]).toMatchObject({
id: "sess-1",
diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.ts b/frontend/src/renderer/hooks/useWorkspaceQuery.ts
index b46373aff..479323c78 100644
--- a/frontend/src/renderer/hooks/useWorkspaceQuery.ts
+++ b/frontend/src/renderer/hooks/useWorkspaceQuery.ts
@@ -44,6 +44,7 @@ async function fetchWorkspaces(): Promise {
id: project.id,
name: project.name,
path: project.path,
+ orchestratorAgent: project.orchestratorAgent ? toAgentProvider(project.orchestratorAgent) : undefined,
sessions: (sessionsData?.sessions ?? [])
.filter((session) => session.projectId === project.id)
.map((session) => ({
diff --git a/frontend/src/renderer/lib/orchestrator-replacement-telemetry.test.ts b/frontend/src/renderer/lib/orchestrator-replacement-telemetry.test.ts
new file mode 100644
index 000000000..e56be81e1
--- /dev/null
+++ b/frontend/src/renderer/lib/orchestrator-replacement-telemetry.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it, vi } from "vitest";
+
+const { captureRendererExceptionMock } = vi.hoisted(() => ({
+ captureRendererExceptionMock: vi.fn(),
+}));
+
+vi.mock("./telemetry", () => ({
+ captureRendererException: captureRendererExceptionMock,
+}));
+
+import { captureOrchestratorReplacementFailure } from "./orchestrator-replacement-telemetry";
+
+describe("captureOrchestratorReplacementFailure", () => {
+ it("records the shell restart-failure telemetry payload", () => {
+ const error = new Error("missing goose binary");
+
+ captureOrchestratorReplacementFailure(error, "proj-1");
+
+ expect(captureRendererExceptionMock).toHaveBeenCalledWith(error, {
+ source: "orchestrator-replace",
+ operation: "replace_orchestrator",
+ surface: "shell",
+ project_id: "proj-1",
+ });
+ });
+});
diff --git a/frontend/src/renderer/lib/orchestrator-replacement-telemetry.ts b/frontend/src/renderer/lib/orchestrator-replacement-telemetry.ts
new file mode 100644
index 000000000..64e28a826
--- /dev/null
+++ b/frontend/src/renderer/lib/orchestrator-replacement-telemetry.ts
@@ -0,0 +1,10 @@
+import { captureRendererException } from "./telemetry";
+
+export function captureOrchestratorReplacementFailure(error: unknown, projectId: string) {
+ void captureRendererException(error, {
+ source: "orchestrator-replace",
+ operation: "replace_orchestrator",
+ surface: "shell",
+ project_id: projectId,
+ });
+}
diff --git a/frontend/src/renderer/lib/restart-orchestrator.test.ts b/frontend/src/renderer/lib/restart-orchestrator.test.ts
new file mode 100644
index 000000000..8b85b9bf8
--- /dev/null
+++ b/frontend/src/renderer/lib/restart-orchestrator.test.ts
@@ -0,0 +1,73 @@
+import { QueryClient } from "@tanstack/react-query";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
+
+const { spawnMock } = vi.hoisted(() => ({
+ spawnMock: vi.fn(),
+}));
+
+vi.mock("./spawn-orchestrator", () => ({
+ spawnOrchestrator: spawnMock,
+}));
+
+import { restartProjectOrchestrator } from "./restart-orchestrator";
+
+describe("restartProjectOrchestrator", () => {
+ beforeEach(() => {
+ spawnMock.mockReset();
+ });
+
+ it("invalidates workspace state and records an error when clean restart fails", async () => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries").mockResolvedValue();
+ const navigate = vi.fn();
+ const setProjectRestarting = vi.fn();
+ const setOrchestratorReplacementError = vi.fn();
+ const onError = vi.fn();
+ const failure = new Error("missing goose binary");
+ spawnMock.mockRejectedValue(failure);
+
+ await restartProjectOrchestrator({
+ projectId: "proj-1",
+ queryClient,
+ navigate,
+ setProjectRestarting,
+ setOrchestratorReplacementError,
+ onError,
+ });
+
+ expect(spawnMock).toHaveBeenCalledWith("proj-1", true);
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: workspaceQueryKey });
+ expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(1, "proj-1", null);
+ expect(setOrchestratorReplacementError).toHaveBeenNthCalledWith(2, "proj-1", "missing goose binary");
+ expect(setProjectRestarting).toHaveBeenNthCalledWith(1, "proj-1", true);
+ expect(setProjectRestarting).toHaveBeenLastCalledWith("proj-1", false);
+ expect(onError).toHaveBeenCalledWith(failure);
+ expect(navigate).not.toHaveBeenCalled();
+ });
+
+ it("still records the replacement error when workspace invalidation fails", async () => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ vi.spyOn(queryClient, "invalidateQueries").mockRejectedValue(new Error("refetch failed"));
+ const navigate = vi.fn();
+ const setProjectRestarting = vi.fn();
+ const setOrchestratorReplacementError = vi.fn();
+ const onError = vi.fn();
+ const failure = new Error("missing goose binary");
+ spawnMock.mockRejectedValue(failure);
+
+ await restartProjectOrchestrator({
+ projectId: "proj-1",
+ queryClient,
+ navigate,
+ setProjectRestarting,
+ setOrchestratorReplacementError,
+ onError,
+ });
+
+ expect(setOrchestratorReplacementError).toHaveBeenLastCalledWith("proj-1", "missing goose binary");
+ expect(setProjectRestarting).toHaveBeenLastCalledWith("proj-1", false);
+ expect(onError).toHaveBeenCalledWith(failure);
+ expect(navigate).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/renderer/lib/restart-orchestrator.ts b/frontend/src/renderer/lib/restart-orchestrator.ts
new file mode 100644
index 000000000..0d41f7981
--- /dev/null
+++ b/frontend/src/renderer/lib/restart-orchestrator.ts
@@ -0,0 +1,52 @@
+import type { QueryClient } from "@tanstack/react-query";
+import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
+import { spawnOrchestrator } from "./spawn-orchestrator";
+
+type NavigateToSession = (options: {
+ to: "/projects/$projectId/sessions/$sessionId";
+ params: { projectId: string; sessionId: string };
+}) => unknown;
+
+type RestartProjectOrchestratorOptions = {
+ projectId: string;
+ queryClient: QueryClient;
+ navigate: NavigateToSession;
+ setProjectRestarting: (projectId: string, restarting: boolean) => void;
+ setOrchestratorReplacementError: (projectId: string, message: string | null) => void;
+ onError?: (error: unknown) => void;
+};
+
+async function refreshWorkspaceState(queryClient: QueryClient) {
+ try {
+ await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
+ } catch {
+ // The restart outcome is more important than cache refresh bookkeeping:
+ // callers still need navigation/error state even if refetching fails.
+ }
+}
+
+export async function restartProjectOrchestrator({
+ projectId,
+ queryClient,
+ navigate,
+ setProjectRestarting,
+ setOrchestratorReplacementError,
+ onError,
+}: RestartProjectOrchestratorOptions) {
+ setProjectRestarting(projectId, true);
+ setOrchestratorReplacementError(projectId, null);
+ try {
+ const sessionId = await spawnOrchestrator(projectId, true);
+ await refreshWorkspaceState(queryClient);
+ void navigate({
+ to: "/projects/$projectId/sessions/$sessionId",
+ params: { projectId, sessionId },
+ });
+ } catch (error) {
+ await refreshWorkspaceState(queryClient);
+ setOrchestratorReplacementError(projectId, error instanceof Error ? error.message : "Could not replace orchestrator");
+ onError?.(error);
+ } finally {
+ setProjectRestarting(projectId, false);
+ }
+}
diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx
index a6f300d71..8891662b2 100644
--- a/frontend/src/renderer/routes/_shell.tsx
+++ b/frontend/src/renderer/routes/_shell.tsx
@@ -2,6 +2,7 @@ import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
import { useQueryClient } from "@tanstack/react-query";
import { type CSSProperties, useCallback, useEffect, useRef } from "react";
import { ShellTopbar } from "../components/ShellTopbar";
+import { OrchestratorReplacementDialog } from "../components/OrchestratorReplacementDialog";
import { Sidebar } from "../components/Sidebar";
import { SidebarProvider } from "../components/ui/sidebar";
import { TitlebarNav } from "../components/TitlebarNav";
@@ -13,6 +14,8 @@ import { refreshDaemonStatus } from "../lib/daemon-status";
import { addRendererExceptionStep, captureRendererEvent, captureRendererException } from "../lib/telemetry";
import { ShellProvider } from "../lib/shell-context";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
+import { restartProjectOrchestrator } from "../lib/restart-orchestrator";
+import { captureOrchestratorReplacementFailure } from "../lib/orchestrator-replacement-telemetry";
import { readStoredTheme, type Theme, useUiStore } from "../stores/ui-store";
import type { WorkspaceSummary } from "../types/workspace";
import type { components } from "../../api/schema";
@@ -48,6 +51,10 @@ function ShellLayout() {
const daemonStatus = useDaemonStatus(queryClient);
const agentCatalogPortRef = useRef(undefined);
const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore();
+ const setProjectRestarting = useUiStore((state) => state.setProjectRestarting);
+ const orchestratorReplacementErrors = useUiStore((state) => state.orchestratorReplacementErrors);
+ const setOrchestratorReplacementError = useUiStore((state) => state.setOrchestratorReplacementError);
+ const replacementErrorProjectId = Object.keys(orchestratorReplacementErrors)[0] ?? null;
const updateWorkspaces = useCallback(
(updater: (workspaces: WorkspaceSummary[]) => WorkspaceSummary[]) => {
@@ -99,6 +106,7 @@ function ShellLayout() {
name: data.project.name,
path: data.project.path,
type: "main",
+ orchestratorAgent: input.orchestratorAgent as WorkspaceSummary["orchestratorAgent"],
sessions: [],
};
void captureRendererEvent("ao.renderer.project_add_succeeded", { project_id: workspace.id });
@@ -146,6 +154,22 @@ function ShellLayout() {
[updateWorkspaces],
);
+ const restartOrchestrator = useCallback(
+ async (projectId: string) => {
+ await restartProjectOrchestrator({
+ projectId,
+ queryClient,
+ navigate,
+ setProjectRestarting,
+ setOrchestratorReplacementError,
+ onError: (error) => {
+ captureOrchestratorReplacementFailure(error, projectId);
+ },
+ });
+ },
+ [navigate, queryClient, setOrchestratorReplacementError, setProjectRestarting],
+ );
+
useEffect(() => {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
@@ -229,6 +253,15 @@ function ShellLayout() {
by window-drag even though DOM hit-testing looks correct. */}
+ {
+ if (!open && replacementErrorProjectId) setOrchestratorReplacementError(replacementErrorProjectId, null);
+ }}
+ onRetry={(projectId) => void restartOrchestrator(projectId)}
+ projectId={replacementErrorProjectId}
+ workspaces={workspaces}
+ />
);
diff --git a/frontend/src/renderer/stores/ui-store.ts b/frontend/src/renderer/stores/ui-store.ts
index 237405512..7d50c8179 100644
--- a/frontend/src/renderer/stores/ui-store.ts
+++ b/frontend/src/renderer/stores/ui-store.ts
@@ -13,11 +13,15 @@ type UiState = {
isSidebarOpen: boolean;
isInspectorOpen: boolean;
theme: Theme;
+ restartingProjectIds: ReadonlySet