fix(web): detect backend restarts in open pages (#3346)

This commit is contained in:
Carlos Florêncio
2026-09-04 04:42:01 +00:00
committed by GitHub
parent b067fac875
commit fa6720f8ca
60 changed files with 2417 additions and 178 deletions
+5 -1
View File
@@ -1049,9 +1049,13 @@ func bootPayload(ctx context.Context, req *http.Request, p routeParams, route we
if route.Route == webapp.RouteTaskDetail && routeData == nil && canLoadTaskDetailFallback(req, p.authSvc) {
bootStateBuilder{p: p}.addHomeKanbanRouteState(ctx, req, initialState)
}
runtime := webRuntimeConfig(p.devMode, p.webTitlePrefix, req)
if p.systemSvc != nil && p.systemSvc.Info != nil {
runtime.BootID = p.systemSvc.Info.Info().BootID
}
payload := webapp.NewBootPayload(
route,
webRuntimeConfig(p.devMode, p.webTitlePrefix, req),
runtime,
initialState,
)
payload.RouteData = routeData
@@ -24,6 +24,8 @@ import (
gateways "github.com/kandev/kandev/internal/gateway/websocket"
"github.com/kandev/kandev/internal/quickterminal"
quickterminalrepo "github.com/kandev/kandev/internal/quickterminal/repository"
systemsvc "github.com/kandev/kandev/internal/system"
systeminfo "github.com/kandev/kandev/internal/system/info"
storagepkg "github.com/kandev/kandev/internal/system/storage"
storageworkspaces "github.com/kandev/kandev/internal/system/storage/workspaces"
taskdto "github.com/kandev/kandev/internal/task/dto"
@@ -1430,6 +1432,34 @@ func TestBootPayloadOmitsUnsetTitlePrefix(t *testing.T) {
}
}
func TestBootPayloadCarriesSystemInfoBootID(t *testing.T) {
t.Parallel()
infoSvc := systeminfo.NewService("version", "commit", "build-time")
payload := bootPayload(
context.Background(),
nil,
routeParams{systemSvc: &systemsvc.Service{Info: infoSvc}},
webapp.ClassifyRoute("/"),
)
raw, err := json.Marshal(payload)
if err != nil {
t.Fatalf("Marshal payload: %v", err)
}
var decoded struct {
Runtime struct {
BootID string `json:"bootId"`
} `json:"runtime"`
}
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("Unmarshal payload: %v", err)
}
if decoded.Runtime.BootID != infoSvc.Info().BootID {
t.Fatalf("runtime.bootId = %q, want %q", decoded.Runtime.BootID, infoSvc.Info().BootID)
}
}
func TestBootPayloadRestoresQuickChatSessions(t *testing.T) {
harness := newBootStateTestHarness(t)
ctx := context.Background()
@@ -15,6 +15,10 @@ import (
// authentication credential.
const InterimSettingsInterlockHeader = "X-Kandev-Interim-Settings-Interlock"
// InterimSettingsInterlockErrorCode identifies a stale browser document
// without requiring clients to match the human-readable error message.
const InterimSettingsInterlockErrorCode = "interim_settings_interlock_required"
// NewInterimSettingsInterlockToken returns a random token for one backend
// boot. Callers must fail closed when token generation fails.
func NewInterimSettingsInterlockToken() (string, error) {
@@ -33,7 +37,10 @@ func InterimSettingsInterlock(token string) gin.HandlerFunc {
return func(c *gin.Context) {
if strings.TrimSpace(token) == "" || hasBearerAuthorization(c.GetHeader("Authorization")) ||
!constantTimeEqual(c.GetHeader(InterimSettingsInterlockHeader), token) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "interim settings interlock required"})
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "interim settings interlock required",
"error_code": InterimSettingsInterlockErrorCode,
})
return
}
c.Next()
@@ -1,6 +1,7 @@
package httpmw
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -40,6 +41,15 @@ func TestInterimSettingsInterlockRejectsMissingWrongBearerAndEmptyServerToken(t
if response.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden)
}
var body struct {
ErrorCode string `json:"error_code"`
}
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
t.Fatalf("decode response body: %v", err)
}
if body.ErrorCode != InterimSettingsInterlockErrorCode {
t.Fatalf("error_code = %q, want %q", body.ErrorCode, InterimSettingsInterlockErrorCode)
}
if called {
t.Fatal("handler was called")
}
+1
View File
@@ -40,6 +40,7 @@ type ActivePluginPayload struct {
type RuntimeConfig struct {
APIPrefix string `json:"apiPrefix"`
WebSocketPath string `json:"webSocketPath"`
BootID string `json:"bootId,omitempty"`
LSPAutoInstallPreferenceLanguages []string `json:"lspAutoInstallPreferenceLanguages,omitempty"`
Debug bool `json:"debug,omitempty"`
// NonProduction marks a dev or e2e build. Distinct from Debug (which the SPA
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({ apiBaseUrl: "http://backend.test" }),
}));
const reloadMocks = vi.hoisted(() => ({
signalBackendReloadRequired: vi.fn(),
}));
vi.mock("@/lib/platform/backend-reload-coordinator", () => ({
signalBackendReloadRequired: (...args: unknown[]) =>
reloadMocks.signalBackendReloadRequired(...args),
}));
import { deleteAgentProfileAction } from "./agents";
const bootWindow = window as unknown as { __KANDEV_BOOT_PAYLOAD__?: unknown };
beforeEach(() => {
bootWindow.__KANDEV_BOOT_PAYLOAD__ = {
interimSettingsInterlockToken: "boot-token",
};
reloadMocks.signalBackendReloadRequired.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
delete bootWindow.__KANDEV_BOOT_PAYLOAD__;
});
describe("deleteAgentProfileAction", () => {
it("returns a handled result for the stale interlock response", async () => {
const fetcher = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
error: "interim settings interlock required",
error_code: "interim_settings_interlock_required",
}),
{ status: 403, headers: { "Content-Type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetcher);
await expect(deleteAgentProfileAction("profile-1")).resolves.toEqual({
status: "error",
message: "interim settings interlock required",
handled: true,
});
expect(reloadMocks.signalBackendReloadRequired).toHaveBeenCalledWith(
"settings_interlock_rejected",
);
expect(
new Headers(fetcher.mock.calls[0]?.[1]?.headers).get("X-Kandev-Interim-Settings-Interlock"),
).toBe("boot-token");
});
it("keeps the typed conflict result for profile deletion", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
active_sessions: [{ id: "session-1" }],
watchers: [],
routing_tiers: [],
automations: [],
utility_agents: [],
}),
{ status: 409, headers: { "Content-Type": "application/json" } },
),
),
);
await expect(deleteAgentProfileAction("profile-1")).resolves.toMatchObject({
status: "conflict",
activeSessions: [{ id: "session-1" }],
});
expect(reloadMocks.signalBackendReloadRequired).not.toHaveBeenCalled();
});
});
+53 -41
View File
@@ -1,8 +1,7 @@
"use client";
import { getBackendConfig } from "@/lib/config";
import { fetchJson } from "@/lib/api/client";
import { readInterimSettingsInterlockToken } from "@/src/boot-payload";
import { ApiError, fetchJson } from "@/lib/api/client";
import type {
Agent,
AgentProfile,
@@ -20,7 +19,6 @@ import type { AgentProfileKind } from "@/lib/types/agent-profile";
type ProfilePermissions = Record<PermissionKey, boolean>;
const { apiBaseUrl } = getBackendConfig();
const interimSettingsInterlockHeader = "X-Kandev-Interim-Settings-Interlock";
type DynamicProfilePayload = {
version: number;
@@ -193,53 +191,67 @@ export type DeleteProfileResult =
automations: AutomationReference[];
utilityAgents: UtilityAgentReference[];
}
| { status: "error"; message: string };
| { status: "error"; message: string; handled?: boolean };
// eslint-disable-next-line complexity
export async function deleteAgentProfileAction(
id: string,
force?: boolean,
): Promise<DeleteProfileResult> {
const url = `${apiBaseUrl}/api/v1/agent-profiles/${id}${force ? "?force=true" : ""}`;
const token = readInterimSettingsInterlockToken();
const response = await fetch(url, {
method: "DELETE",
cache: "no-store",
headers: {
"Content-Type": "application/json",
...(token ? { [interimSettingsInterlockHeader]: token } : {}),
},
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
// A 409 is active sessions, referencing watchers, routing tier mappings, or a mix.
// Treat any non-empty list as the conflict signal — a watcher-only
// conflict (the new self-heal path) must still pop the dialog.
if (
response.status === 409 &&
(body.active_sessions ||
body.watchers ||
body.routing_tiers ||
body.automations ||
body.utility_agents)
) {
return {
status: "conflict",
activeSessions: body.active_sessions ?? [],
watchers: body.watchers ?? [],
routingTiers: body.routing_tiers ?? [],
automations: body.automations ?? [],
utilityAgents: body.utility_agents ?? [],
};
}
// i18n-exempt: server-provided error text, or an HTTP status diagnostic when
// the server sent none. The toast title around it is translated.
try {
await agentSettingsRequest<void>(url, { method: "DELETE" });
return { status: "ok" };
} catch (error) {
return deleteAgentProfileError(error);
}
}
type DeleteProfileErrorBody = {
error?: string;
active_sessions?: ActiveSessionInfo[];
watchers?: WatcherReference[];
routing_tiers?: RoutingTierReference[];
automations?: AutomationReference[];
utility_agents?: UtilityAgentReference[];
};
function readDeleteProfileErrorBody(value: unknown): DeleteProfileErrorBody {
return value && typeof value === "object" ? (value as DeleteProfileErrorBody) : {};
}
function hasDeleteProfileConflict(body: DeleteProfileErrorBody): boolean {
return Boolean(
body.active_sessions ||
body.watchers ||
body.routing_tiers ||
body.automations ||
body.utility_agents,
);
}
function deleteAgentProfileError(error: unknown): DeleteProfileResult {
const apiError = error instanceof ApiError ? error : undefined;
const body = apiError ? readDeleteProfileErrorBody(apiError.body) : {};
// A 409 is active sessions, referencing watchers, routing tier mappings, or a mix.
// Treat any non-empty list as the conflict signal — a watcher-only
// conflict (the new self-heal path) must still pop the dialog.
if (apiError?.status === 409 && hasDeleteProfileConflict(body)) {
return {
status: "error",
message: body?.error || `Request failed: ${response.status} ${response.statusText}`,
status: "conflict",
activeSessions: body.active_sessions ?? [],
watchers: body.watchers ?? [],
routingTiers: body.routing_tiers ?? [],
automations: body.automations ?? [],
utilityAgents: body.utility_agents ?? [],
};
}
return { status: "ok" };
// i18n-exempt: server-provided error text, or an HTTP status diagnostic when
// the server sent none. The toast title around it is translated.
return {
status: "error",
message: error instanceof Error ? error.message : "Request failed",
...(apiError?.handled ? { handled: true } : {}),
};
}
export async function getAgentProfileMcpConfigAction(
@@ -29,6 +29,7 @@ import { SettingsRedirect } from "@/src/settings-route-helpers";
import { saveNewAgent, saveExistingAgent, isProfileDirty } from "./agent-save-helpers";
import type { DraftProfile, DraftAgent } from "./agent-save-helpers";
import { AgentHeader, ProfilesCard } from "./agent-setup-parts";
import { isHandledApiError } from "@/lib/api/client";
const defaultMcpConfig: NonNullable<DraftProfile["mcp_config"]> = {
enabled: false,
@@ -582,6 +583,7 @@ export default function AgentSetupPage() {
if (!initialAgent) return null;
const handleToastError = (error: unknown) => {
if (isHandledApiError(error)) return;
toast({
title: t("agents:failedToSaveAgent"),
description: error instanceof Error ? error.message : t("agents:requestFailed"),
@@ -1,15 +1,26 @@
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import { ToastProvider } from "@/components/toast-provider";
import { ApiError, INTERIM_SETTINGS_INTERLOCK_ERROR_CODE } from "@/lib/api/client";
import type { Agent } from "@/lib/types/http-agents";
const { updateMcpStrategy, toast } = vi.hoisted(() => ({
updateMcpStrategy: vi.fn(),
toast: vi.fn(),
}));
// The strategy list is fetched on mount; the mount assertion below is about
// whether the control is reachable at all, not about the option list.
vi.mock("@/lib/api/domains/settings-api", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
listMCPStrategies: vi.fn(async () => [{ key: "claude", description: "a config file" }]),
updateCustomTUIAgentMCPStrategy: vi.fn(),
updateCustomTUIAgentMCPStrategy: updateMcpStrategy,
}));
vi.mock("@/components/toast-provider", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useToast: () => ({ toast }),
}));
const { CustomTUIMcpCard } = await import("@/components/settings/custom-tui-mcp-card");
@@ -74,6 +85,7 @@ function renderIndexComposition(saved: Agent | undefined, name = "fuel-claude")
describe("custom TUI MCP control on the agents index", () => {
afterEach(cleanup);
afterEach(() => vi.clearAllMocks());
it("renders for a saved custom TUI agent", async () => {
renderIndexComposition(tuiAgent());
@@ -106,4 +118,20 @@ describe("custom TUI MCP control on the agents index", () => {
renderIndexComposition(undefined);
expect(screen.queryByTestId(STRATEGY_SELECT)).toBeNull();
});
it("does not toast a handled stale-page update error", async () => {
const staleError = new ApiError("interim settings interlock required", 403, {
error_code: INTERIM_SETTINGS_INTERLOCK_ERROR_CODE,
});
staleError.handled = true;
updateMcpStrategy.mockRejectedValueOnce(staleError);
renderIndexComposition(tuiAgent());
fireEvent.click(await screen.findByTestId(STRATEGY_SELECT));
fireEvent.click(await screen.findByRole("option", { name: /claude/ }));
await waitFor(() => expect(updateMcpStrategy).toHaveBeenCalledOnce());
expect(toast).not.toHaveBeenCalled();
expect(screen.queryByText(staleError.message)).toBeNull();
});
});
@@ -18,6 +18,7 @@ import {
createAgentProfileAction,
updateAgentProfileAction,
} from "@/app/actions/agents";
import { isHandledApiError } from "@/lib/api/client";
import type { Agent, AgentProfile, AvailableAgent, CLIFlag } from "@/lib/types/http";
import { seedDefaultCLIFlags } from "@/lib/cli-flags";
import {
@@ -171,6 +172,9 @@ export function CliProfileEditor({
? await saveExistingProfile(profile.id, form)
: await saveNewProfile(form, settingsAgents);
onSaved(saved);
} catch (error) {
if (isHandledApiError(error)) return;
throw error;
} finally {
setSaving(false);
}
@@ -22,6 +22,8 @@ import { AppStatusDrawer } from "./app-status-drawer";
import { connectionIssueDetails } from "./connection-status-item";
import type { ConnectionIssueSeverity } from "@/lib/types/connection";
import { AgentRuntimeUnavailableAlert } from "./agent-runtime-unavailable-alert";
import { BackendReloadRequiredAlert } from "./backend-reload-required-alert";
import { useBackendGenerationGuard } from "@/hooks/domains/system/use-backend-generation-guard";
type AppStatusDrawerContextValue = {
enabled: boolean;
@@ -111,6 +113,7 @@ function drawerTriggerVisibilityClass(connectionOnly: boolean) {
}
export function AppStatusSurfaceProvider({ children }: { children: ReactNode }) {
useBackendGenerationGuard();
const [drawerOpen, setStatusDrawerOpen] = useState(false);
const pathname = usePathname();
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
@@ -162,6 +165,7 @@ export function AppStatusSurfaceProvider({ children }: { children: ReactNode })
<AppStatusDrawerContext.Provider value={drawer}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<AgentRuntimeUnavailableAlert />
<BackendReloadRequiredAlert />
{children}
{(useDrawerSurface ? drawerEnabled : inlineStatusBarVisible) &&
(useDrawerSurface ? (
@@ -0,0 +1,90 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BackendReloadSnapshot } from "@/lib/platform/backend-reload-coordinator";
const reloadState = vi.hoisted(() => ({
snapshot: {
reloadRequired: false,
source: null as BackendReloadSnapshot["source"],
ownerCount: 0,
},
listeners: new Set<() => void>(),
}));
vi.mock("@/lib/platform/backend-reload-coordinator", () => ({
backendReloadCoordinator: {
getSnapshot: () => reloadState.snapshot,
subscribe: (listener: () => void) => {
reloadState.listeners.add(listener);
return () => reloadState.listeners.delete(listener);
},
},
}));
import { BackendReloadRequiredAlert } from "./backend-reload-required-alert";
function setSnapshot(snapshot: typeof reloadState.snapshot): void {
reloadState.snapshot = snapshot;
reloadState.listeners.forEach((listener) => listener());
}
beforeEach(() => {
reloadState.snapshot = { reloadRequired: false, source: null, ownerCount: 0 };
reloadState.listeners.clear();
});
afterEach(cleanup);
describe("BackendReloadRequiredAlert", () => {
it("shows one persistent reload action after a proven restart", () => {
act(() => {
setSnapshot({ reloadRequired: true, source: "boot_id_changed", ownerCount: 0 });
});
render(<BackendReloadRequiredAlert />);
expect(screen.getByTestId("backend-reload-required-alert")).toBeTruthy();
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText("Reload required")).toBeTruthy();
expect(
screen.getByText(
"Kandev restarted. Reload this page to continue. Reloading discards unsaved changes.",
),
).toBeTruthy();
expect(screen.getByRole("button", { name: "Reload page" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
});
it("keeps the global alert hidden while an intentional restart owns recovery", () => {
setSnapshot({ reloadRequired: true, source: "boot_id_changed", ownerCount: 1 });
render(<BackendReloadRequiredAlert />);
expect(screen.queryByTestId("backend-reload-required-alert")).toBeNull();
act(() => {
setSnapshot({ reloadRequired: true, source: "boot_id_changed", ownerCount: 0 });
});
expect(screen.getByTestId("backend-reload-required-alert")).toBeTruthy();
});
it("reloads the current document only after the user activates the action", () => {
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
setSnapshot({ reloadRequired: true, source: "boot_id_changed", ownerCount: 0 });
render(<BackendReloadRequiredAlert />);
expect(reload).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Reload page" }));
expect(reload).toHaveBeenCalledOnce();
});
it("uses a touch-sized full-width action in the phone layout", () => {
setSnapshot({ reloadRequired: true, source: "boot_id_changed", ownerCount: 0 });
render(<BackendReloadRequiredAlert />);
const action = screen.getByRole("button", { name: "Reload page" });
expect(action.className).toContain("h-11");
expect(action.className).toContain("w-full");
});
});
@@ -0,0 +1,44 @@
"use client";
import { useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { IconAlertTriangle } from "@tabler/icons-react";
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
import { Button } from "@kandev/ui/button";
import { backendReloadCoordinator } from "@/lib/platform/backend-reload-coordinator";
export function BackendReloadRequiredAlert() {
const { t } = useTranslation();
const snapshot = useSyncExternalStore(
backendReloadCoordinator.subscribe,
backendReloadCoordinator.getSnapshot,
backendReloadCoordinator.getSnapshot,
);
if (!snapshot.reloadRequired || snapshot.ownerCount > 0) return null;
return (
<div
className="min-w-0 w-full shrink-0 px-3 pt-3 sm:px-4"
data-testid="backend-reload-required-alert"
>
<Alert role="alert" variant="destructive" className="min-w-0 gap-x-2 px-3 py-3 sm:px-4">
<IconAlertTriangle className="mt-0.5 size-4" aria-hidden="true" />
<AlertTitle className="min-w-0 break-words text-sm">
{t("system:backendReloadRequiredTitle")}
</AlertTitle>
<AlertDescription className="col-start-2 flex min-w-0 flex-col gap-3 text-sm sm:flex-row sm:items-start sm:justify-between">
<p className="min-w-0 break-words">{t("system:backendReloadRequiredBody")}</p>
<Button
type="button"
size="sm"
className="h-11 w-full shrink-0 cursor-pointer sm:w-auto"
onClick={() => window.location.reload()}
>
{t("system:backendReloadRequiredAction")}
</Button>
</AlertDescription>
</Alert>
</div>
);
}
@@ -0,0 +1,172 @@
import type { ReactNode } from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OnboardingDialog } from "./onboarding-dialog";
const apiMocks = vi.hoisted(() => ({
listAvailableAgents: vi.fn(),
listWorkflowTemplates: vi.fn(),
}));
const actionMocks = vi.hoisted(() => ({
listAgentsAction: vi.fn(),
updateAgentProfileAction: vi.fn(),
}));
const staleSave = vi.hoisted(() => new Error("stale settings save"));
const coordinatorState = vi.hoisted(() => ({
snapshot: {
reloadRequired: false,
source: null as "boot_id_changed" | "settings_interlock_rejected" | null,
ownerCount: 0,
},
listeners: new Set<() => void>(),
}));
vi.mock("@/lib/api", () => apiMocks);
vi.mock("@/app/actions/agents", () => actionMocks);
vi.mock("@/lib/api/client", () => ({
isHandledApiError: (error: unknown) => error === staleSave,
}));
vi.mock("@/lib/platform/backend-reload-coordinator", () => ({
backendReloadCoordinator: {
getSnapshot: () => coordinatorState.snapshot,
subscribe: (listener: () => void) => {
coordinatorState.listeners.add(listener);
return () => coordinatorState.listeners.delete(listener);
},
},
}));
vi.mock("@kandev/ui/dialog", () => {
const Content = ({ children }: { children: ReactNode }) => <div>{children}</div>;
return {
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <div data-testid="onboarding-dialog">{children}</div> : null,
DialogContent: Content,
DialogHeader: Content,
DialogTitle: Content,
DialogFooter: Content,
DialogDescription: Content,
};
});
vi.mock("@/components/onboarding/step-agents", () => ({
StepAgents: ({
agentSettings,
onUpdateSetting,
}: {
agentSettings: Record<string, unknown>;
onUpdateSetting: (agentName: string, formPatch: { model: string }) => void;
}) => (
<button
type="button"
disabled={!agentSettings["test-agent"]}
onClick={() => onUpdateSetting("test-agent", { model: "changed-model" })}
>
Make agent dirty
</button>
),
}));
const availableAgent = {
name: "test-agent",
display_name: "Test Agent",
model_config: {
default_model: "default-model",
current_mode_id: "default-mode",
status: "ok",
available_models: [],
},
permission_settings: {},
};
const savedAgent = {
name: "test-agent",
profiles: [
{
id: "profile-1",
name: "Default",
model: "default-model",
mode: "default-mode",
allowIndexing: false,
autoApprove: false,
cliPassthrough: false,
cliFlags: [],
commandPrefix: "",
},
],
};
function signalReloadRequired() {
coordinatorState.snapshot = {
reloadRequired: true,
source: "settings_interlock_rejected",
ownerCount: 0,
};
coordinatorState.listeners.forEach((listener) => listener());
}
beforeEach(() => {
vi.clearAllMocks();
coordinatorState.snapshot = { reloadRequired: false, source: null, ownerCount: 0 };
coordinatorState.listeners.clear();
apiMocks.listAvailableAgents.mockResolvedValue({ agents: [availableAgent], tools: [] });
apiMocks.listWorkflowTemplates.mockResolvedValue({ templates: [] });
actionMocks.listAgentsAction.mockResolvedValue({ agents: [savedAgent] });
actionMocks.updateAgentProfileAction.mockResolvedValue({});
});
afterEach(cleanup);
describe("OnboardingDialog backend restart recovery", () => {
it("keeps the wizard on the agent step after a handled stale save", async () => {
actionMocks.updateAgentProfileAction.mockRejectedValue(staleSave);
const onComplete = vi.fn();
render(<OnboardingDialog open onComplete={onComplete} />);
const dirtyButton = (await screen.findByRole("button", {
name: "Make agent dirty",
})) as HTMLButtonElement;
await waitFor(() => expect(dirtyButton.disabled).toBe(false));
fireEvent.click(dirtyButton);
fireEvent.click(screen.getByRole("button", { name: "Next" }));
await waitFor(() => expect(actionMocks.updateAgentProfileAction).toHaveBeenCalledOnce());
expect(screen.getByText("AI Agents")).toBeTruthy();
expect(screen.getByRole("button", { name: "Next" })).toBeTruthy();
expect(onComplete).not.toHaveBeenCalled();
});
it("blocks get started and yields the modal when the final save is stale", async () => {
let saveCount = 0;
actionMocks.updateAgentProfileAction.mockImplementation(async () => {
saveCount += 1;
if (saveCount === 2) {
signalReloadRequired();
throw staleSave;
}
return {};
});
const onComplete = vi.fn();
render(<OnboardingDialog open onComplete={onComplete} />);
const dirtyButton = (await screen.findByRole("button", {
name: "Make agent dirty",
})) as HTMLButtonElement;
await waitFor(() => expect(dirtyButton.disabled).toBe(false));
fireEvent.click(dirtyButton);
fireEvent.click(screen.getByRole("button", { name: "Next" }));
await waitFor(() => expect(screen.getByText("Executors")).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: "Next" }));
await waitFor(() => expect(screen.getByText("Agentic Workflows")).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: "Next" }));
await waitFor(() => expect(screen.getByText("Command Panel")).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: "Get Started" }));
await waitFor(() => expect(actionMocks.updateAgentProfileAction).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.queryByTestId("onboarding-dialog")).toBeNull());
expect(onComplete).not.toHaveBeenCalled();
});
});
+31 -18
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import {
Dialog,
DialogContent,
@@ -32,6 +32,8 @@ import { type ProfileFormData } from "@/components/settings/profile-form-fields"
import { permissionsToProfilePatch, profilePermissionValues } from "@/lib/agent-permissions";
import { listAvailableAgents, listWorkflowTemplates } from "@/lib/api";
import { listAgentsAction, updateAgentProfileAction } from "@/app/actions/agents";
import { isHandledApiError } from "@/lib/api/client";
import { backendReloadCoordinator } from "@/lib/platform/backend-reload-coordinator";
import { StepAgents, type AgentSetting } from "@/components/onboarding/step-agents";
import type { AvailableAgent, ToolStatus, WorkflowTemplate, AgentProfile } from "@/lib/types/http";
import { Trans, useTranslation } from "react-i18next";
@@ -261,6 +263,11 @@ function OnboardingFooter({ step, onSkip, onBack, onNext, onGetStarted }: Onboar
export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
const { t } = useTranslation();
const reloadRequired = useSyncExternalStore(
backendReloadCoordinator.subscribe,
backendReloadCoordinator.getSnapshot,
backendReloadCoordinator.getSnapshot,
).reloadRequired;
const [step, setStep] = useState(0);
const {
availableAgents,
@@ -272,20 +279,26 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
loadingTemplates,
} = useOnboardingResources(open);
const saveAgentSettings = useCallback(async () => {
await Promise.all(
Object.values(agentSettings)
.filter((s) => s.dirty)
.map((s) =>
updateAgentProfileAction(s.profileId, {
model: s.formData.model,
...permissionsToProfilePatch(s.formData),
cli_passthrough: s.formData.cli_passthrough,
cli_flags: s.formData.cli_flags,
command_prefix: s.formData.command_prefix,
}),
),
);
const saveAgentSettings = useCallback(async (): Promise<boolean> => {
try {
await Promise.all(
Object.values(agentSettings)
.filter((s) => s.dirty)
.map((s) =>
updateAgentProfileAction(s.profileId, {
model: s.formData.model,
...permissionsToProfilePatch(s.formData),
cli_passthrough: s.formData.cli_passthrough,
cli_flags: s.formData.cli_flags,
command_prefix: s.formData.command_prefix,
}),
),
);
return true;
} catch (error) {
if (isHandledApiError(error)) return false;
throw error;
}
}, [agentSettings]);
const handleSkip = () => {
@@ -293,14 +306,14 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
setStep(0);
};
const handleNext = async () => {
if (step === 0) await saveAgentSettings();
if (step === 0 && !(await saveAgentSettings())) return;
if (step < TOTAL_STEPS - 1) setStep(step + 1);
};
const handleBack = () => {
if (step > 0) setStep(step - 1);
};
const handleGetStarted = async () => {
await saveAgentSettings();
if (!(await saveAgentSettings())) return;
onComplete();
setStep(0);
};
@@ -316,7 +329,7 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
};
return (
<Dialog open={open} onOpenChange={() => {}}>
<Dialog open={open && !reloadRequired} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-3xl" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="text-center text-2xl">{t(STEP_TITLE_KEYS[step])}</DialogTitle>
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { ApiError, INTERIM_SETTINGS_INTERLOCK_ERROR_CODE } from "@/lib/api/client";
import { AddTUIAgentDialog } from "./add-tui-agent-dialog";
afterEach(cleanup);
@@ -69,4 +70,22 @@ describe("AddTUIAgentDialog", () => {
await waitFor(() => expect(screen.getByText("Failed to create agent")).toBeTruthy());
expect(onSubmit).toHaveBeenCalledOnce();
});
it("closes without exposing a handled stale-page error", async () => {
const staleError = new ApiError("interim settings interlock required", 403, {
error_code: INTERIM_SETTINGS_INTERLOCK_ERROR_CODE,
});
staleError.handled = true;
const onSubmit = vi.fn().mockRejectedValue(staleError);
const onOpenChange = vi.fn();
render(<AddTUIAgentDialog open onOpenChange={onOpenChange} onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "superclaude" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "superclaude" } });
fireEvent.click(screen.getByText("Create"));
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
expect(screen.queryByText(staleError.message)).toBeNull();
expect(onSubmit).toHaveBeenCalledOnce();
});
});
@@ -3,6 +3,7 @@
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { t as translate } from "@/lib/i18n";
import { isHandledApiError } from "@/lib/api/client";
import { Button } from "@kandev/ui/button";
import {
Dialog,
@@ -78,6 +79,11 @@ function useDialogHandlers({
reset();
onOpenChange(false);
} catch (err) {
if (isHandledApiError(err)) {
reset();
onOpenChange(false);
return;
}
setError(err instanceof Error ? err.message : translate("agents:failedToCreateAgent"));
} finally {
setLoading(false);
@@ -15,6 +15,8 @@ import { applyProfileDuplicated } from "@/hooks/domains/settings/use-profile-dup
import { errorMessage } from "@/components/settings/agent-profile-page-state";
import { useRouter } from "@/lib/routing/client-router";
import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard";
import { getBackendReloadSnapshot } from "@/lib/platform/backend-reload-coordinator";
import { isHandledApiError } from "@/lib/api/client";
import type { Agent, AgentProfile } from "@/lib/types/http";
/**
@@ -71,6 +73,7 @@ export function useProfileDuplicateAction({
// it reports canLeave=false and we abort before posting.
const saved = await saveAll();
if (!saved.canLeave) {
if (getBackendReloadSnapshot().reloadRequired) return;
// Surface whichever contributor is blocking the save (MCP error or
// an invalid profile draft); when the coordinator gives no reason
// (another save in flight, or newer changes appeared during the
@@ -106,6 +109,7 @@ export function useProfileDuplicateAction({
router.push(`/settings/agents/${encodeURIComponent(agent.name)}/profiles/${created.id}`),
);
} catch (error) {
if (isHandledApiError(error)) return;
toast({
title: translate("agents:failedToDuplicateProfile"),
description: errorMessage(error),
@@ -22,7 +22,7 @@ import {
toAgentProfileOption,
type AgentProfileOption,
} from "@/lib/state/slices/settings/types";
import { ApiError } from "@/lib/api/client";
import { ApiError, isHandledApiError } from "@/lib/api/client";
import type { Agent, AgentProfile, PermissionSetting } from "@/lib/types/http";
export type SaveStatus = "idle" | "loading" | "success" | "error";
@@ -164,11 +164,13 @@ export function useProfileSave({
);
}
setSaveStatus("error");
toast({
title: translate("agents:failedToSaveProfile"),
description: errorMessage(error),
variant: "error",
});
if (!isHandledApiError(error)) {
toast({
title: translate("agents:failedToSaveProfile"),
description: errorMessage(error),
variant: "error",
});
}
throw error;
}
};
@@ -222,7 +224,7 @@ export function useProfileDelete(
automations: result.automations,
utilityAgents: result.utilityAgents,
});
} else {
} else if (!result.handled) {
toast({
title: translate("agents:failedToDeleteProfile"),
description: result.message,
@@ -244,7 +246,7 @@ export function useProfileDelete(
automations: result.automations,
utilityAgents: result.utilityAgents,
});
} else if (result.status === "error") {
} else if (result.status === "error" && !result.handled) {
toast({
title: translate("agents:failedToDeleteProfile"),
description: result.message,
@@ -63,6 +63,7 @@ import { useAgentProfileSettings } from "@/app/settings/agents/[agentId]/profile
import { agentProfileDiscoveryTarget } from "@/lib/settings-discovery/dynamic-targets";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { DynamicAgentProfileEditor } from "@/components/settings/dynamic-agent-profile-editor";
import { isHandledApiError } from "@/lib/api/client";
type ProfileEditorProps = {
agent: Agent;
@@ -472,13 +473,14 @@ function ProfileEditor({
passthroughConfig={passthroughConfig}
secrets={secrets}
initialMcpConfig={initialMcpConfig}
onToastError={(error) =>
onToastError={(error) => {
if (isHandledApiError(error)) return;
toast({
title: t("agents:failedToSaveMcpConfig"),
description: errorMessage(error),
variant: "error",
})
}
});
}}
onModelConfigResolutionPendingChange={setModelConfigResolutionPending}
/>
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiError, INTERIM_SETTINGS_INTERLOCK_ERROR_CODE } from "@/lib/api/client";
import { enqueueAgentInstall } from "./agent-install-catalog";
const { installAgent } = vi.hoisted(() => ({
installAgent: vi.fn(),
}));
vi.mock("@/lib/api", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
installAgent,
}));
afterEach(() => {
vi.clearAllMocks();
});
function handledStaleError() {
const error = new ApiError("interim settings interlock required", 403, {
error_code: INTERIM_SETTINGS_INTERLOCK_ERROR_CODE,
});
error.handled = true;
return error;
}
describe("enqueueAgentInstall", () => {
it("does not create a local failed job for a handled stale-page error", async () => {
installAgent.mockRejectedValueOnce(handledStaleError());
const upsertInstallJob = vi.fn();
await enqueueAgentInstall("claude-acp", upsertInstallJob);
expect(upsertInstallJob).not.toHaveBeenCalled();
});
});
@@ -11,6 +11,7 @@ import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
import { installAgent, listAgentDiscovery, listAvailableAgents, listInstallJobs } from "@/lib/api";
import type { InstallJob } from "@/lib/api";
import { isHandledApiError } from "@/lib/api/client";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import type { AvailableAgent, ToolStatus } from "@/lib/types/http";
@@ -129,6 +130,27 @@ function ToolInstallCard({
);
}
export async function enqueueAgentInstall(
name: string,
upsertInstallJob: (job: InstallJob) => void,
): Promise<void> {
try {
const job = await installAgent(name);
// The WS event will normally arrive first, but seed the store in case the
// WS round-trip is slower than the HTTP response.
upsertInstallJob(job);
} catch (err) {
if (isHandledApiError(err)) return;
upsertInstallJob({
job_id: `local-error-${name}`,
agent_name: name,
status: "failed",
error: err instanceof Error ? err.message : String(err),
started_at: new Date().toISOString(),
});
}
}
/**
* Install state is held in the store (driven by WS events
* agent.install.{started,output,finished}). This hook:
@@ -173,22 +195,7 @@ function useInstallAgent(onSuccess: () => Promise<void>) {
}, [Object.values(installJobs).filter((j) => j.status === "succeeded").length]);
const handleInstall = useCallback(
async (name: string) => {
try {
const job = await installAgent(name);
// The WS event will normally arrive first, but seed the store in case
// the WS round-trip is slower than the HTTP response.
upsertInstallJob(job);
} catch (err) {
upsertInstallJob({
job_id: `local-error-${name}`,
agent_name: name,
status: "failed",
error: err instanceof Error ? err.message : String(err),
started_at: new Date().toISOString(),
});
}
},
(name: string) => enqueueAgentInstall(name, upsertInstallJob),
[upsertInstallJob],
);
@@ -174,6 +174,7 @@ type ProfileRowDeleteConfirmationProps = {
onConfirm: () => void | Promise<void>;
placement: "inline" | "popover";
};
type ProfileRowDeleteConfirmationBaseProps = Omit<ProfileRowDeleteConfirmationProps, "placement">;
function ProfileRowDeleteConfirmation({
open,
@@ -204,6 +205,81 @@ function ProfileRowDeleteConfirmation({
);
}
type ProfileRowCardProps = {
profile: AgentProfile;
href: string;
confirmOpen: boolean;
isFinePointer: boolean;
isFullDesktop: boolean;
deleteAnchorRef: RefObject<HTMLButtonElement | null>;
onDuplicate: () => void;
onConfirmDelete: () => void;
confirmationProps: ProfileRowDeleteConfirmationBaseProps;
};
function ProfileRowCard({
profile,
href,
confirmOpen,
isFinePointer,
isFullDesktop,
deleteAnchorRef,
onDuplicate,
onConfirmDelete,
confirmationProps,
}: ProfileRowCardProps) {
return (
<Card
// Same surface treatment as the workspace section tiles.
className="relative gap-0 border-border/70 bg-background/50 py-1.5 transition-colors hover:border-foreground/30 hover:bg-muted/50"
data-testid="agent-profile-row"
>
{/* Whole-card link as an overlay — the action buttons sit above it (z-10). */}
<Link
href={href}
aria-label={profile.name}
className="absolute inset-0"
data-testid="agent-profile-row-link"
/>
<CardContent className="flex items-center justify-between gap-2 px-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<RecordDot />
<span className="truncate text-sm font-medium">{profile.name}</span>
{profile.enabled === false && <DisabledBadge />}
</div>
{(profile.model || profile.mode) && (
<div className="mt-0.5 flex flex-wrap items-center gap-1.5 pl-3.5">
{profile.model && <Badge variant="outline">{profile.model}</Badge>}
{profile.mode && <Badge variant="secondary">{profile.mode}</Badge>}
</div>
)}
</div>
<div className="relative z-10 flex shrink-0 items-center gap-1">
{!(confirmOpen && !isFinePointer) &&
(isFullDesktop ? (
<ProfileRowInlineActions
profile={profile}
deleteAnchorRef={deleteAnchorRef}
onDuplicate={onDuplicate}
onConfirmDelete={onConfirmDelete}
/>
) : (
<ProfileRowActions
profile={profile}
deleteAnchorRef={deleteAnchorRef}
onDuplicate={onDuplicate}
onConfirmDelete={onConfirmDelete}
/>
))}
</div>
<ProfileRowDeleteConfirmation {...confirmationProps} placement="inline" />
</CardContent>
<ProfileRowDeleteConfirmation {...confirmationProps} placement="popover" />
</Card>
);
}
/** One saved profile as a fully clickable row — shared by the Agents index and the agent page. */
export function ProfileRow({ agent, profile }: { agent: Agent; profile: AgentProfile }) {
const { t } = useTranslation();
@@ -249,6 +325,10 @@ export function ProfileRow({ agent, profile }: { agent: Agent; profile: AgentPro
router.push(href);
return;
}
if (result.handled) {
closeDeleteConfirmation();
return;
}
toast({
title: t("agents:cannotDeleteAgentProfile"),
description: result.message,
@@ -265,53 +345,16 @@ export function ProfileRow({ agent, profile }: { agent: Agent; profile: AgentPro
onConfirm: () => void handleDelete(),
};
return (
<Card
// Same surface treatment as the workspace section tiles.
className="relative gap-0 border-border/70 bg-background/50 py-1.5 transition-colors hover:border-foreground/30 hover:bg-muted/50"
data-testid="agent-profile-row"
>
{/* Whole-card link as an overlay — the action buttons sit above it (z-10). */}
<Link
href={href}
aria-label={profile.name}
className="absolute inset-0"
data-testid="agent-profile-row-link"
/>
<CardContent className="flex items-center justify-between gap-2 px-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<RecordDot />
<span className="truncate text-sm font-medium">{profile.name}</span>
{profile.enabled === false && <DisabledBadge />}
</div>
{(profile.model || profile.mode) && (
<div className="mt-0.5 flex flex-wrap items-center gap-1.5 pl-3.5">
{profile.model && <Badge variant="outline">{profile.model}</Badge>}
{profile.mode && <Badge variant="secondary">{profile.mode}</Badge>}
</div>
)}
</div>
<div className="relative z-10 flex shrink-0 items-center gap-1">
{!(confirmOpen && !isFinePointer) &&
(isFullDesktop ? (
<ProfileRowInlineActions
profile={profile}
deleteAnchorRef={deleteAnchorRef}
onDuplicate={() => void handleDuplicate(agent, profile)}
onConfirmDelete={() => setConfirmOpen(true)}
/>
) : (
<ProfileRowActions
profile={profile}
deleteAnchorRef={deleteAnchorRef}
onDuplicate={() => void handleDuplicate(agent, profile)}
onConfirmDelete={() => setConfirmOpen(true)}
/>
))}
</div>
<ProfileRowDeleteConfirmation {...confirmationProps} placement="inline" />
</CardContent>
<ProfileRowDeleteConfirmation {...confirmationProps} placement="popover" />
</Card>
<ProfileRowCard
profile={profile}
href={href}
confirmOpen={confirmOpen}
isFinePointer={isFinePointer}
isFullDesktop={isFullDesktop}
deleteAnchorRef={deleteAnchorRef}
onDuplicate={() => void handleDuplicate(agent, profile)}
onConfirmDelete={() => setConfirmOpen(true)}
confirmationProps={confirmationProps}
/>
);
}
@@ -6,6 +6,7 @@ import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { MCPStrategySelect, useMCPStrategies } from "@/components/settings/mcp-strategy-select";
import { updateCustomTUIAgentMCPStrategy } from "@/lib/api/domains/settings-api";
import { isHandledApiError } from "@/lib/api/client";
import type { Agent } from "@/lib/types/http-agents";
type CustomTUIMcpCardProps = {
@@ -54,6 +55,7 @@ export function CustomTUIMcpCard({ agent }: CustomTUIMcpCardProps) {
),
);
} catch (error) {
if (isHandledApiError(error)) return;
toast({
title: t("agents:failedToSaveAgent"),
description: error instanceof Error ? error.message : t("agents:requestFailed"),
@@ -7,6 +7,7 @@ import { useToast } from "@/components/toast-provider";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { isDynamicErrorPolicyValid } from "@/components/settings/dynamic-agent-policy-editor";
import { updateAgentProfileAction } from "@/app/actions/agents";
import { isHandledApiError } from "@/lib/api/client";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { toAgentProfileOption } from "@/lib/state/slices/settings/types";
import type { Agent, AgentProfile } from "@/lib/types/http";
@@ -204,6 +205,7 @@ export function useDynamicAgentProfileEditorState({
);
toast({ title: t("agents:dynamicProfileSaved") });
} catch (error) {
if (isHandledApiError(error)) return;
toast({
title: t("agents:failedToSaveProfile"),
description: error instanceof Error ? error.message : undefined,
@@ -1,6 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { AgentUpdateJob, AgentUpdatePreview } from "@/lib/api";
import { ApiError, INTERIM_SETTINGS_INTERLOCK_ERROR_CODE } from "@/lib/api/client";
import { useAgentUpdateDialogState } from "./use-agent-update-dialog-state";
function deferred<T>() {
@@ -121,6 +122,35 @@ describe("useAgentUpdateDialogState", () => {
});
});
describe("useAgentUpdateDialogState handled failures", () => {
it("closes without exposing a handled stale-page approval error", async () => {
const staleError = new ApiError("interim settings interlock required", 403, {
error_code: INTERIM_SETTINGS_INTERLOCK_ERROR_CODE,
});
staleError.handled = true;
const onUpdate = vi.fn().mockRejectedValue(staleError);
const { result } = renderHook(() =>
useAgentUpdateDialogState({
agentName: AGENT_NAME,
onPreview: vi.fn().mockResolvedValue(FIRST_PREVIEW),
onUpdate,
}),
);
act(() => result.current.handleOpenChange(true));
await waitFor(() => expect(result.current.preview).toEqual(FIRST_PREVIEW));
await act(async () => {
await result.current.approve();
});
expect(result.current.open).toBe(false);
expect(result.current.approveError).toBeNull();
expect(result.current.activeJob).toBeUndefined();
expect(result.current.starting).toBe(false);
expect(onUpdate).toHaveBeenCalledOnce();
});
});
describe("useAgentUpdateDialogState target selection", () => {
it("keeps the current preview while a selected target is loading", async () => {
const selectedTargetPreview = deferred<AgentUpdatePreview>();
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { AgentUpdateJob, AgentUpdatePreview } from "@/lib/api";
import { isHandledApiError } from "@/lib/api/client";
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
@@ -57,6 +58,20 @@ function resetDialogState({
setActiveJobID(null);
}
function handleApprovalError(
error: unknown,
requestID: number,
previewRequestID: { current: number },
setApproveError: (value: string | null) => void,
onHandledError: () => void,
) {
if (isHandledApiError(error)) {
onHandledError();
return;
}
if (requestID === previewRequestID.current) setApproveError(errorMessage(error));
}
async function approveRuntimeUpdate({
requestID,
previewRequestID,
@@ -68,6 +83,7 @@ async function approveRuntimeUpdate({
setStarting,
setApproveError,
setActiveJobID,
onHandledError,
}: {
requestID: number;
previewRequestID: { current: number };
@@ -79,6 +95,7 @@ async function approveRuntimeUpdate({
setStarting: (value: boolean) => void;
setApproveError: (value: string | null) => void;
setActiveJobID: (value: string | null) => void;
onHandledError: () => void;
}) {
const targetVersion = selectedUseDefault
? preview?.default_version || preview?.target_version || ""
@@ -92,12 +109,17 @@ async function approveRuntimeUpdate({
: await onUpdate(agentName, targetVersion);
if (requestID === previewRequestID.current) setActiveJobID(nextJob.job_id);
} catch (error) {
if (requestID === previewRequestID.current) setApproveError(errorMessage(error));
handleApprovalError(error, requestID, previewRequestID, setApproveError, onHandledError);
} finally {
if (requestID === previewRequestID.current) setStarting(false);
}
}
function closeHandledDialog(setOpen: (value: boolean) => void, reset: () => void) {
setOpen(false);
reset();
}
function handleDialogOpenChange(
nextOpen: boolean,
setOpen: (value: boolean) => void,
@@ -196,6 +218,8 @@ export function useAgentUpdateDialogState({
setSelectedUseDefault,
);
const closeOnHandledError = useCallback(() => closeHandledDialog(setOpen, reset), [reset]);
useEffect(() => {
if (open) void loadPreview();
}, [loadPreview, open]);
@@ -215,8 +239,9 @@ export function useAgentUpdateDialogState({
setStarting,
setApproveError,
setActiveJobID,
onHandledError: closeOnHandledError,
}),
[agentName, onUpdate, preview, selectedTarget, selectedUseDefault],
[agentName, closeOnHandledError, onUpdate, preview, selectedTarget, selectedUseDefault],
);
return {
@@ -0,0 +1,40 @@
import { expect, test } from "../../fixtures/test-base";
import { waitForHttp } from "../../helpers/causal-waits";
test.describe("Backend restart page recovery", () => {
test("shows one reload alert on the current non-settings route", async ({
testPage,
backend,
prCapture,
}) => {
test.setTimeout(120_000);
await testPage.goto("/stats");
await expect(testPage.getByTestId("app-shell")).toBeVisible();
await expect(testPage.getByTestId("backend-reload-required-alert")).toHaveCount(0);
await testPage.waitForLoadState("networkidle");
const systemInfoRecovered = waitForHttp(testPage, "GET", /^\/api\/v1\/system\/info$/, {
predicate: (response) => response.ok(),
});
await backend.restart();
await systemInfoRecovered;
const alert = testPage.getByTestId("backend-reload-required-alert");
await expect(alert).toBeVisible();
await expect(alert).toContainText("Reload required");
await expect(alert).toContainText("Kandev restarted");
await expect(testPage.getByRole("button", { name: "Reload page" })).toHaveCount(1);
await expect(testPage).toHaveURL(/\/stats$/);
await prCapture.screenshot("backend-restart-page-recovery-desktop", {
caption: "Persistent desktop backend restart recovery alert",
});
await alert.getByRole("button", { name: "Reload page" }).click();
await expect(testPage.getByTestId("app-shell")).toBeVisible();
await expect(testPage.getByTestId("backend-reload-required-alert")).toHaveCount(0, {
timeout: 30_000,
});
await expect(testPage).toHaveURL(/\/stats$/);
});
});
@@ -0,0 +1,51 @@
import { expect, test } from "../../fixtures/test-base";
import { waitForHttp } from "../../helpers/causal-waits";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
test.describe("Mobile backend restart page recovery", () => {
test("keeps the recovery action reachable without horizontal overflow", async ({
testPage,
backend,
prCapture,
}) => {
test.setTimeout(120_000);
await testPage.goto("/stats");
await expect(testPage.getByTestId("app-shell")).toBeVisible();
await testPage.waitForLoadState("networkidle");
const systemInfoRecovered = waitForHttp(testPage, "GET", /^\/api\/v1\/system\/info$/, {
predicate: (response) => response.ok(),
});
await backend.restart();
await systemInfoRecovered;
const alert = testPage.getByTestId("backend-reload-required-alert");
await expect(alert).toBeVisible();
const action = alert.getByRole("button", { name: "Reload page" });
const actionBox = await action.boundingBox();
expect(actionBox, "backend restart recovery action has no rendered hitbox").not.toBeNull();
expect(actionBox?.height ?? 0).toBeGreaterThanOrEqual(44);
const main = testPage.locator("main").first();
const alertBox = await alert.boundingBox();
const mainBox = await main.boundingBox();
expect(alertBox).not.toBeNull();
expect(mainBox).not.toBeNull();
if (alertBox && mainBox) {
expect(alertBox.y + alertBox.height).toBeLessThanOrEqual(mainBox.y + 1);
}
await assertNoDocumentHorizontalOverflow(testPage, "mobile backend restart recovery");
await prCapture.screenshot("mobile-backend-restart-page-recovery", {
caption: "Persistent mobile backend restart recovery alert",
});
await action.click();
await expect(testPage.getByTestId("app-shell")).toBeVisible();
await expect(testPage.getByTestId("backend-reload-required-alert")).toHaveCount(0, {
timeout: 30_000,
});
await assertNoDocumentHorizontalOverflow(testPage, "mobile backend restart recovery reload");
});
});
@@ -1,17 +1,30 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { create, type StoreApi } from "zustand";
import { immer } from "zustand/middleware/immer";
import { ApiError } from "@/lib/api/client";
import type { Agent, AgentProfile } from "@/lib/types/http";
import type { AgentProfileOption } from "@/lib/state/slices/settings/types";
import { createSettingsSlice } from "@/lib/state/slices/settings/settings-slice";
import type { SettingsSlice } from "@/lib/state/slices/settings/types";
import type { AppState } from "@/lib/state/store";
import { registerAgentsHandlers } from "@/lib/ws/handlers/agents";
import { applyProfileDuplicated } from "./use-profile-duplicate";
import { applyProfileDuplicated, useProfileDuplicate } from "./use-profile-duplicate";
vi.mock("@/app/actions/agents", () => ({ duplicateAgentProfileAction: vi.fn() }));
vi.mock("@/components/state-provider", () => ({ useAppStoreApi: vi.fn() }));
vi.mock("@/components/toast-provider", () => ({ useToast: vi.fn() }));
const mocks = vi.hoisted(() => ({
duplicateAgentProfileAction: vi.fn(),
setState: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/app/actions/agents", () => ({
duplicateAgentProfileAction: (...args: unknown[]) => mocks.duplicateAgentProfileAction(...args),
}));
vi.mock("@/components/state-provider", () => ({
useAppStoreApi: () => ({ setState: mocks.setState }),
}));
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/lib/i18n", () => ({ t: (key: string) => key }));
const COPY_NAME = "Default Copy";
const OLD_REVISION = "2026-08-11T21:00:00Z";
@@ -214,6 +227,23 @@ describe("applyProfileDuplicated", () => {
});
});
describe("useProfileDuplicate", () => {
it("does not toast a stale interlock error already handled by the coordinator", async () => {
const error = new ApiError("stale document", 403, {
error_code: "interim_settings_interlock_required",
});
error.handled = true;
mocks.duplicateAgentProfileAction.mockRejectedValue(error);
const { result } = renderHook(() => useProfileDuplicate());
await act(async () => {
await result.current(agent("a1"), profile("p1", "a1"));
});
expect(mocks.toast).not.toHaveBeenCalled();
});
});
describe("applyProfileDuplicated capability_status sync", () => {
it("picks up a fresh agent.available.updated capability instead of reverting to the stale settingsAgents value", () => {
// Regression for the desync where agent.available.updated only refreshed
@@ -2,6 +2,7 @@
import { useCallback, useRef } from "react";
import { duplicateAgentProfileAction } from "@/app/actions/agents";
import { isHandledApiError } from "@/lib/api/client";
import { useAppStoreApi } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { t as translate } from "@/lib/i18n";
@@ -110,6 +111,7 @@ export function useProfileDuplicate() {
variant: "success",
});
} catch (error) {
if (isHandledApiError(error)) return;
toast({
title: translate("agents:failedToDuplicateProfile"),
description: error instanceof Error ? error.message : translate("agents:requestFailed"),
@@ -1,10 +1,23 @@
import { describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/lib/api/client";
import type { Agent, AgentProfile } from "@/lib/types/http";
import { applyEnabledProfileUpdate } from "./use-profile-enabled-toggle";
import { applyEnabledProfileUpdate, useProfileEnabledToggle } from "./use-profile-enabled-toggle";
vi.mock("@/app/actions/agents", () => ({ updateAgentProfileAction: vi.fn() }));
vi.mock("@/components/state-provider", () => ({ useAppStoreApi: vi.fn() }));
vi.mock("@/components/toast-provider", () => ({ useToast: vi.fn() }));
const mocks = vi.hoisted(() => ({
updateAgentProfileAction: vi.fn(),
setState: vi.fn(),
toast: vi.fn(),
}));
vi.mock("@/app/actions/agents", () => ({
updateAgentProfileAction: (...args: unknown[]) => mocks.updateAgentProfileAction(...args),
}));
vi.mock("@/components/state-provider", () => ({
useAppStoreApi: () => ({ setState: mocks.setState }),
}));
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/lib/i18n", () => ({ t: (key: string) => key }));
function profile(id: string, agentId: string, enabled: boolean): AgentProfile {
return {
@@ -67,3 +80,22 @@ describe("applyEnabledProfileUpdate", () => {
).toEqual([false, false]);
});
});
describe("useProfileEnabledToggle", () => {
beforeEach(() => vi.clearAllMocks());
it("does not toast a stale interlock error already handled by the coordinator", async () => {
const error = new ApiError("stale document", 403, {
error_code: "interim_settings_interlock_required",
});
error.handled = true;
mocks.updateAgentProfileAction.mockRejectedValue(error);
const { result } = renderHook(() => useProfileEnabledToggle());
await act(async () => {
await result.current(profile("p1", "a1", true), false);
});
expect(mocks.toast).not.toHaveBeenCalled();
});
});
@@ -2,6 +2,7 @@
import { useCallback } from "react";
import { updateAgentProfileAction } from "@/app/actions/agents";
import { isHandledApiError } from "@/lib/api/client";
import { useAppStoreApi } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { t as translate } from "@/lib/i18n";
@@ -58,6 +59,7 @@ export function useProfileEnabledToggle() {
const updated = await updateAgentProfileAction(profile.id, { enabled });
storeApi.setState((state) => applyEnabledProfileUpdate(state, profile, updated));
} catch (error) {
if (isHandledApiError(error)) return;
toast({
title: translate("agents:failedToUpdateProfile"),
description: error instanceof Error ? error.message : translate("agents:requestFailed"),
@@ -0,0 +1,159 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { StrictMode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionStatus } from "@/lib/types/connection";
const mocks = vi.hoisted(() => ({
state: { connection: { status: "connected" as ConnectionStatus } },
fetchSystemInfo: vi.fn(),
signalBackendReloadRequired: vi.fn(),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state),
}));
vi.mock("@/lib/api/domains/system-api", () => ({
fetchSystemInfo: (...args: unknown[]) => mocks.fetchSystemInfo(...args),
}));
vi.mock("@/lib/platform/backend-reload-coordinator", () => ({
signalBackendReloadRequired: (...args: unknown[]) => mocks.signalBackendReloadRequired(...args),
}));
import { useBackendGenerationGuard } from "./use-backend-generation-guard";
function setBootId(bootId: unknown): void {
(window as unknown as { __KANDEV_BOOT_PAYLOAD__?: unknown }).__KANDEV_BOOT_PAYLOAD__ = {
runtime: { bootId },
};
}
function setConnectionStatus(status: ConnectionStatus): void {
mocks.state.connection.status = status;
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
beforeEach(() => {
setBootId("boot-1");
setConnectionStatus("connected");
mocks.fetchSystemInfo.mockReset();
mocks.signalBackendReloadRequired.mockReset();
});
afterEach(() => {
delete (window as unknown as { __KANDEV_BOOT_PAYLOAD__?: unknown }).__KANDEV_BOOT_PAYLOAD__;
});
describe("useBackendGenerationGuard", () => {
it("checks the live boot id after the initial connection", async () => {
mocks.fetchSystemInfo.mockResolvedValue({ boot_id: "boot-1" });
renderHook(() => useBackendGenerationGuard());
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledWith({ cache: "no-store" }));
expect(mocks.signalBackendReloadRequired).not.toHaveBeenCalled();
});
it("checks the live boot id once when StrictMode replays effects", async () => {
mocks.fetchSystemInfo.mockResolvedValue({ boot_id: "boot-2" });
renderHook(() => useBackendGenerationGuard(), { wrapper: StrictMode });
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1));
expect(mocks.signalBackendReloadRequired).toHaveBeenCalledOnce();
});
it("does not signal a deferred request after the final StrictMode unmount", async () => {
const request = deferred<{ boot_id: string }>();
mocks.fetchSystemInfo.mockReturnValue(request.promise);
const hook = renderHook(() => useBackendGenerationGuard(), { wrapper: StrictMode });
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1));
hook.unmount();
await act(async () => {
request.resolve({ boot_id: "boot-2" });
await request.promise;
});
expect(mocks.signalBackendReloadRequired).not.toHaveBeenCalled();
});
it("signals a changed boot id after a reconnect", async () => {
mocks.fetchSystemInfo.mockResolvedValue({ boot_id: "boot-2" });
const hook = renderHook(() => useBackendGenerationGuard());
await waitFor(() =>
expect(mocks.signalBackendReloadRequired).toHaveBeenCalledWith("boot_id_changed"),
);
expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1);
setConnectionStatus("reconnecting");
hook.rerender();
setConnectionStatus("connected");
hook.rerender();
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(2));
expect(mocks.signalBackendReloadRequired).toHaveBeenCalledTimes(2);
});
it("ignores missing, equal, and failed identities", async () => {
mocks.fetchSystemInfo
.mockResolvedValueOnce({ boot_id: "boot-1" })
.mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error("backend unavailable"));
const hook = renderHook(() => useBackendGenerationGuard());
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1));
for (const status of ["reconnecting", "connected", "reconnecting", "connected"] as const) {
setConnectionStatus(status);
hook.rerender();
if (status === "connected") {
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalled());
}
}
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(3));
expect(mocks.signalBackendReloadRequired).not.toHaveBeenCalled();
});
it("ignores an older identity response after a newer connection", async () => {
const first = deferred<{ boot_id: string }>();
const second = deferred<{ boot_id: string }>();
mocks.fetchSystemInfo.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const hook = renderHook(() => useBackendGenerationGuard());
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1));
setConnectionStatus("reconnecting");
hook.rerender();
setConnectionStatus("connected");
hook.rerender();
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(2));
await act(async () => {
second.resolve({ boot_id: "boot-1" });
await second.promise;
first.resolve({ boot_id: "boot-2" });
await first.promise;
});
expect(mocks.signalBackendReloadRequired).not.toHaveBeenCalled();
});
it("does not compare when the document has no boot id", async () => {
setBootId(42);
mocks.fetchSystemInfo.mockResolvedValue({ boot_id: "boot-2" });
renderHook(() => useBackendGenerationGuard());
await waitFor(() => expect(mocks.fetchSystemInfo).toHaveBeenCalledTimes(1));
expect(mocks.signalBackendReloadRequired).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,50 @@
"use client";
import { useEffect, useRef } from "react";
import { useAppStore } from "@/components/state-provider";
import { fetchSystemInfo } from "@/lib/api/domains/system-api";
import { signalBackendReloadRequired } from "@/lib/platform/backend-reload-coordinator";
import { readBootPayload } from "@/src/boot-payload";
/**
* Confirms that a recovered WebSocket belongs to the backend generation that
* rendered this document. A failed or incomplete check is deliberately
* inconclusive, so it cannot interrupt a usable page without proof.
*/
export function useBackendGenerationGuard(): void {
const connectionStatus = useAppStore((state) => state.connection.status);
const pageBootIdRef = useRef(readBootPayload().runtime?.bootId);
const previousStatusRef = useRef<typeof connectionStatus | null>(null);
const requestSequenceRef = useRef(0);
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
const cleanup = () => {
mountedRef.current = false;
};
if (connectionStatus !== "connected") {
previousStatusRef.current = connectionStatus;
requestSequenceRef.current += 1;
return cleanup;
}
if (previousStatusRef.current === "connected") return cleanup;
previousStatusRef.current = "connected";
const requestSequence = ++requestSequenceRef.current;
void fetchSystemInfo({ cache: "no-store" })
.then((info) => {
if (!mountedRef.current || requestSequence !== requestSequenceRef.current) return;
const pageBootId = pageBootIdRef.current?.trim();
const liveBootId = info.boot_id?.trim();
if (!pageBootId || !liveBootId || pageBootId === liveBootId) return;
signalBackendReloadRequired("boot_id_changed");
})
.catch(() => {
// A failed request does not prove that the backend generation changed.
});
return cleanup;
}, [connectionStatus]);
}
@@ -4,18 +4,28 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
fetchSystemInfo: vi.fn(),
requestRestart: vi.fn(),
registerBackendReloadOwner: vi.fn(),
releaseBackendReloadOwner: vi.fn(),
}));
vi.mock("@/lib/api/domains/system-api", () => ({
fetchSystemInfo: mocks.fetchSystemInfo,
requestRestart: mocks.requestRestart,
}));
vi.mock("@/lib/platform/backend-reload-coordinator", () => ({
registerBackendReloadOwner: () => {
mocks.registerBackendReloadOwner();
return mocks.releaseBackendReloadOwner;
},
}));
import { useKandevRestart } from "./use-kandev-restart";
beforeEach(() => {
mocks.fetchSystemInfo.mockReset();
mocks.requestRestart.mockReset();
mocks.registerBackendReloadOwner.mockReset();
mocks.releaseBackendReloadOwner.mockReset();
});
describe("useKandevRestart", () => {
@@ -33,6 +43,7 @@ describe("useKandevRestart", () => {
});
expect(mocks.requestRestart).toHaveBeenCalledTimes(1);
expect(mocks.registerBackendReloadOwner).toHaveBeenCalledTimes(1);
await waitFor(() => expect(result.current.phase).toBe("done"));
expect(onComplete).toHaveBeenCalledTimes(1);
});
@@ -69,6 +80,7 @@ describe("useKandevRestart", () => {
);
expect(result.current.phase).toBe("error");
expect(result.current.errorMessage).toBe("unsupported launch mode");
expect(mocks.releaseBackendReloadOwner).toHaveBeenCalledTimes(1);
});
it("ignores duplicate starts while a restart is already active", async () => {
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchSystemInfo, requestRestart } from "@/lib/api/domains/system-api";
import { registerBackendReloadOwner } from "@/lib/platform/backend-reload-coordinator";
// The module-level `t` (not the hook) so these resolve when the callback fires
// and stay out of the callbacks' dependency arrays. This file has no JSX, so
// `mode: "jsx-only"` can never see the strings below.
@@ -32,15 +33,30 @@ export function useKandevRestart({
const [previousBootID, setPreviousBootID] = useState<string | null>(null);
const activeRef = useRef(false);
const startedAtRef = useRef<number | null>(null);
const ownerReleaseRef = useRef<(() => void) | null>(null);
const fail = useCallback((message: string) => {
activeRef.current = false;
setErrorMessage(message);
setPhase("error");
const claimReloadOwnership = useCallback(() => {
if (!ownerReleaseRef.current) ownerReleaseRef.current = registerBackendReloadOwner();
}, []);
const releaseReloadOwnership = useCallback(() => {
ownerReleaseRef.current?.();
ownerReleaseRef.current = null;
}, []);
const fail = useCallback(
(message: string) => {
activeRef.current = false;
releaseReloadOwnership();
setErrorMessage(message);
setPhase("error");
},
[releaseReloadOwnership],
);
const start = useCallback(async () => {
if (activeRef.current) return;
claimReloadOwnership();
activeRef.current = true;
setErrorMessage(null);
setPhase("starting");
@@ -53,15 +69,18 @@ export function useKandevRestart({
} catch (e) {
fail(e instanceof Error ? e.message : t("system:restartStartFailed"));
}
}, [fail]);
}, [claimReloadOwnership, fail]);
const dismiss = useCallback(() => {
activeRef.current = false;
releaseReloadOwnership();
setPhase("idle");
setErrorMessage(null);
setPreviousBootID(null);
startedAtRef.current = null;
}, []);
}, [releaseReloadOwnership]);
useEffect(() => releaseReloadOwnership, [releaseReloadOwnership]);
useEffect(() => {
if (phase !== "restarting") return;
@@ -5,6 +5,8 @@ const mocks = vi.hoisted(() => ({
applyUpdate: vi.fn(),
fetchSystemInfo: vi.fn(),
fetchSystemJob: vi.fn(),
registerBackendReloadOwner: vi.fn(),
releaseBackendReloadOwner: vi.fn(),
}));
vi.mock("@/lib/api/domains/system-api", () => ({
@@ -12,6 +14,12 @@ vi.mock("@/lib/api/domains/system-api", () => ({
fetchSystemInfo: mocks.fetchSystemInfo,
fetchSystemJob: mocks.fetchSystemJob,
}));
vi.mock("@/lib/platform/backend-reload-coordinator", () => ({
registerBackendReloadOwner: () => {
mocks.registerBackendReloadOwner();
return mocks.releaseBackendReloadOwner;
},
}));
import { useSelfUpdate } from "./use-self-update";
@@ -29,6 +37,8 @@ beforeEach(() => {
mocks.applyUpdate.mockReset();
mocks.fetchSystemInfo.mockReset();
mocks.fetchSystemJob.mockReset();
mocks.registerBackendReloadOwner.mockReset();
mocks.releaseBackendReloadOwner.mockReset();
mocks.fetchSystemJob.mockResolvedValue({ state: "succeeded" });
localStorage.clear();
});
@@ -51,6 +61,7 @@ function registerUpdateLifecycleTests() {
expect(result.current.phase).toBe("installing");
expect(result.current.isUpdating).toBe(true);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY) as string).target).toBe("v1.0.1");
expect(mocks.registerBackendReloadOwner).toHaveBeenCalledTimes(1);
});
it("finishes when the version flips to the target", async () => {
@@ -99,6 +110,7 @@ function registerUpdateLifecycleTests() {
expect(result.current.phase).toBe("error");
expect(result.current.errorMessage).toBe("rate limited");
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(mocks.releaseBackendReloadOwner).toHaveBeenCalledTimes(1);
});
it("errors when the launch job reports failure", async () => {
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { applyUpdate, fetchSystemInfo, fetchSystemJob } from "@/lib/api/domains/system-api";
import { registerBackendReloadOwner } from "@/lib/platform/backend-reload-coordinator";
// Module-level `t`: resolved at call time, no JSX in this file for lint to see.
import { t } from "@/lib/i18n";
@@ -79,6 +80,39 @@ export type SelfUpdateController = {
dismiss: () => void;
};
function useSelfUpdateReloadOwnership(
phase: SelfUpdatePhase,
claimReloadOwnership: () => void,
releaseReloadOwnership: () => void,
): void {
useEffect(() => {
if (phase === "idle" || phase === "error") releaseReloadOwnership();
else claimReloadOwnership();
}, [claimReloadOwnership, phase, releaseReloadOwnership]);
useEffect(() => releaseReloadOwnership, [releaseReloadOwnership]);
}
function useResumeSelfUpdate(
hydrated: { current: boolean },
setPhase: (phase: SelfUpdatePhase) => void,
): void {
useEffect(() => {
if (hydrated.current) return;
hydrated.current = true;
const persisted = readPersisted();
if (!persisted) return;
if (Date.now() - persisted.startedAt > MAX_DURATION_MS) {
clearPersisted();
return;
}
// Resume an in-progress update after a page reload. Promoting out of "idle"
// here keeps the first client render matching the server's.
// eslint-disable-next-line react-hooks/set-state-in-effect
setPhase("installing");
}, [hydrated, setPhase]);
}
export function useSelfUpdate({
latestVersion,
onComplete,
@@ -94,15 +128,30 @@ export function useSelfUpdate({
const [jobId, setJobId] = useState<string | null>(null);
const hydrated = useRef(false);
const completionFired = useRef(false);
const ownerReleaseRef = useRef<(() => void) | null>(null);
const fail = useCallback((message: string) => {
setErrorMessage(message);
setPhase("error");
clearPersisted();
const claimReloadOwnership = useCallback(() => {
if (!ownerReleaseRef.current) ownerReleaseRef.current = registerBackendReloadOwner();
}, []);
const releaseReloadOwnership = useCallback(() => {
ownerReleaseRef.current?.();
ownerReleaseRef.current = null;
}, []);
const fail = useCallback(
(message: string) => {
releaseReloadOwnership();
setErrorMessage(message);
setPhase("error");
clearPersisted();
},
[releaseReloadOwnership],
);
const start = useCallback(async () => {
if (!latestVersion) return;
claimReloadOwnership();
completionFired.current = false;
setErrorMessage(null);
setTargetVersion(latestVersion);
@@ -115,33 +164,22 @@ export function useSelfUpdate({
} catch (e) {
fail(e instanceof Error ? e.message : t("system:selfUpdateStartFailed"));
}
}, [latestVersion, fail]);
}, [claimReloadOwnership, latestVersion, fail]);
const dismiss = useCallback(() => {
releaseReloadOwnership();
completionFired.current = false;
setPhase("idle");
setErrorMessage(null);
setJobId(null);
clearPersisted();
}, []);
}, [releaseReloadOwnership]);
useSelfUpdateReloadOwnership(phase, claimReloadOwnership, releaseReloadOwnership);
// Resume an in-progress update after a page reload (the restart window can
// outlive a manual refresh). Runs once; the version poll below confirms done.
useEffect(() => {
if (hydrated.current) return;
hydrated.current = true;
const persisted = readPersisted();
if (!persisted) return;
if (Date.now() - persisted.startedAt > MAX_DURATION_MS) {
clearPersisted();
return;
}
// Resume an in-progress update after a page reload. Promoting out of "idle"
// here (rather than in the initial state) keeps the first client render
// matching the server's; the version poll below then confirms completion.
// eslint-disable-next-line react-hooks/set-state-in-effect
setPhase("installing");
}, []);
useResumeSelfUpdate(hydrated, setPhase);
// Drive the flow off the live version. While installing/restarting, poll
// /system/info: success at the target version → done; an unreachable backend
+58
View File
@@ -1,6 +1,15 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiError, fetchBlob, fetchJson, setOnUnauthorized } from "./client";
const reloadMocks = vi.hoisted(() => ({
signalBackendReloadRequired: vi.fn(),
}));
vi.mock("@/lib/platform/backend-reload-coordinator", () => ({
signalBackendReloadRequired: (...args: unknown[]) =>
reloadMocks.signalBackendReloadRequired(...args),
}));
const interlockToken = "replayable-per-boot-value";
const BACKEND_URL = "http://backend.test";
@@ -113,6 +122,7 @@ describe("fetchJson", () => {
describe("ApiError response classification", () => {
afterEach(() => {
vi.unstubAllGlobals();
reloadMocks.signalBackendReloadRequired.mockReset();
});
it("preserves machine-readable error codes from the backend", async () => {
@@ -133,6 +143,54 @@ describe("ApiError response classification", () => {
errorCode: "branch_policy_stale",
});
});
it("handles only the stale settings interlock response", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
error: "interim settings interlock required",
error_code: "interim_settings_interlock_required",
}),
{ status: 403, headers: { "Content-Type": "application/json" } },
),
),
);
await expect(
fetchJson("/api/v1/agent-profiles/p1", { baseUrl: BACKEND_URL }),
).rejects.toMatchObject({
name: "ApiError",
status: 403,
errorCode: "interim_settings_interlock_required",
handled: true,
});
expect(reloadMocks.signalBackendReloadRequired).toHaveBeenCalledWith(
"settings_interlock_rejected",
);
});
it("leaves other forbidden responses unhandled", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "forbidden" }), {
status: 403,
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(
fetchJson("/api/v1/agent-profiles/p1", { baseUrl: BACKEND_URL }),
).rejects.toMatchObject({
name: "ApiError",
status: 403,
handled: false,
});
expect(reloadMocks.signalBackendReloadRequired).not.toHaveBeenCalled();
});
});
describe("fetchBlob", () => {
+13 -1
View File
@@ -1,7 +1,9 @@
import { getBackendConfig } from "@/lib/config";
import { signalBackendReloadRequired } from "@/lib/platform/backend-reload-coordinator";
import { readInterimSettingsInterlockToken } from "@/src/boot-payload";
const interimSettingsInterlockHeader = "X-Kandev-Interim-Settings-Interlock";
export const INTERIM_SETTINGS_INTERLOCK_ERROR_CODE = "interim_settings_interlock_required";
export type ApiRequestOptions = {
baseUrl?: string;
@@ -19,6 +21,7 @@ export class ApiError extends Error {
readonly body: unknown;
readonly errorCode?: string;
readonly retryAfterSeconds?: number;
handled = false;
constructor(message: string, status: number, body: unknown, retryAfterSeconds?: number) {
super(message);
this.name = "ApiError";
@@ -70,12 +73,21 @@ async function throwFromResponse(response: Response): Promise<never> {
if (typeof errVal === "string") message = errVal;
}
const retryAfter = Number(response.headers.get("Retry-After"));
throw new ApiError(
const error = new ApiError(
message,
response.status,
body,
Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : undefined,
);
if (error.errorCode === INTERIM_SETTINGS_INTERLOCK_ERROR_CODE) {
signalBackendReloadRequired("settings_interlock_rejected");
error.handled = true;
}
throw error;
}
export function isHandledApiError(error: unknown): error is ApiError & { handled: true } {
return error instanceof ApiError && error.handled;
}
export async function fetchJson<T>(pathOrUrl: string, options?: ApiRequestOptions): Promise<T> {
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from "vitest";
import { createBackendReloadCoordinator } from "./backend-reload-coordinator";
describe("backend reload coordinator", () => {
it("latches the first signal and reports it once", () => {
const report = vi.fn();
const coordinator = createBackendReloadCoordinator({ reporter: report });
const listener = vi.fn();
coordinator.subscribe(listener);
expect(coordinator.getSnapshot()).toEqual({
reloadRequired: false,
source: null,
ownerCount: 0,
});
expect(coordinator.signal("boot_id_changed")).toBe(true);
expect(coordinator.signal("settings_interlock_rejected")).toBe(false);
expect(coordinator.getSnapshot()).toEqual({
reloadRequired: true,
source: "boot_id_changed",
ownerCount: 0,
});
expect(listener).toHaveBeenCalledTimes(1);
expect(report).toHaveBeenCalledOnce();
expect(report).toHaveBeenCalledWith("boot_id_changed");
});
it("reports a latched signal when the reporter is installed later", () => {
const coordinator = createBackendReloadCoordinator();
const report = vi.fn();
coordinator.signal("settings_interlock_rejected");
coordinator.setDiagnosticReporter(report);
coordinator.setDiagnosticReporter(report);
expect(report).toHaveBeenCalledOnce();
expect(report).toHaveBeenCalledWith("settings_interlock_rejected");
});
it("tracks idempotent intentional restart owners", () => {
const coordinator = createBackendReloadCoordinator();
const firstRelease = coordinator.registerOwner();
const secondRelease = coordinator.registerOwner();
expect(coordinator.getSnapshot().ownerCount).toBe(2);
firstRelease();
firstRelease();
expect(coordinator.getSnapshot().ownerCount).toBe(1);
secondRelease();
expect(coordinator.getSnapshot().ownerCount).toBe(0);
secondRelease();
expect(coordinator.getSnapshot().ownerCount).toBe(0);
});
});
@@ -0,0 +1,99 @@
export type BackendReloadSignal = "boot_id_changed" | "settings_interlock_rejected";
export type BackendReloadSnapshot = {
reloadRequired: boolean;
source: BackendReloadSignal | null;
ownerCount: number;
};
export type BackendReloadDiagnosticReporter = (source: BackendReloadSignal) => void;
export type BackendReloadCoordinator = {
getSnapshot: () => BackendReloadSnapshot;
subscribe: (listener: () => void) => () => void;
signal: (source: BackendReloadSignal) => boolean;
registerOwner: () => () => void;
setDiagnosticReporter: (reporter: BackendReloadDiagnosticReporter | null) => void;
};
const INITIAL_SNAPSHOT: BackendReloadSnapshot = {
reloadRequired: false,
source: null,
ownerCount: 0,
};
export function createBackendReloadCoordinator(
options: {
reporter?: BackendReloadDiagnosticReporter;
} = {},
): BackendReloadCoordinator {
let snapshot = INITIAL_SNAPSHOT;
let reporter = options.reporter ?? null;
let reportSent = false;
const listeners = new Set<() => void>();
const notify = () => {
listeners.forEach((listener) => listener());
};
const report = (source: BackendReloadSignal) => {
if (reportSent || !reporter) return;
reportSent = true;
try {
reporter(source);
} catch {
// Diagnostics must never change the recovery state.
}
};
const signal = (source: BackendReloadSignal): boolean => {
if (snapshot.reloadRequired) return false;
snapshot = { reloadRequired: true, source, ownerCount: snapshot.ownerCount };
notify();
report(source);
return true;
};
const registerOwner = (): (() => void) => {
let released = false;
snapshot = { ...snapshot, ownerCount: snapshot.ownerCount + 1 };
notify();
return () => {
if (released) return;
released = true;
snapshot = { ...snapshot, ownerCount: Math.max(0, snapshot.ownerCount - 1) };
notify();
};
};
return {
getSnapshot: () => snapshot,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
signal,
registerOwner,
setDiagnosticReporter: (nextReporter) => {
reporter = nextReporter;
if (snapshot.reloadRequired && snapshot.source) report(snapshot.source);
},
};
}
export const backendReloadCoordinator = createBackendReloadCoordinator();
export const getBackendReloadSnapshot = () => backendReloadCoordinator.getSnapshot();
export const subscribeToBackendReload = (listener: () => void) =>
backendReloadCoordinator.subscribe(listener);
export const signalBackendReloadRequired = (source: BackendReloadSignal) =>
backendReloadCoordinator.signal(source);
export const registerBackendReloadOwner = () => backendReloadCoordinator.registerOwner();
export const setBackendReloadDiagnosticReporter = (
reporter: BackendReloadDiagnosticReporter | null,
) => backendReloadCoordinator.setDiagnosticReporter(reporter);
+20
View File
@@ -85,6 +85,26 @@ describe("readBootPayload", () => {
expect(readBootPayload(win).runtime?.titlePrefix).toBe("TEST");
});
it("keeps the backend boot identity from the runtime block", () => {
const win = {
__KANDEV_BOOT_PAYLOAD__: { runtime: { bootId: "boot-123" } },
} as unknown as Window;
expect(readBootPayload(win)).toMatchObject({ runtime: { bootId: "boot-123" } });
});
it("ignores missing or malformed backend boot identities", () => {
const missing = {
__KANDEV_BOOT_PAYLOAD__: { runtime: {} },
} as unknown as Window;
const malformed = {
__KANDEV_BOOT_PAYLOAD__: { runtime: { bootId: 123 } },
} as unknown as Window;
expect(readBootPayload(missing).runtime?.bootId).toBeUndefined();
expect(readBootPayload(malformed).runtime?.bootId).toBeUndefined();
});
it("leaves the title prefix undefined when the runtime block omits it", () => {
const win = {
__KANDEV_BOOT_PAYLOAD__: { runtime: { apiPrefix: "/api/v1" } },
+2
View File
@@ -23,6 +23,7 @@ export type BootRoute = {
export type BootRuntime = {
apiPrefix?: string;
webSocketPath?: string;
bootId?: string;
lspAutoInstallPreferenceLanguages?: string[];
debug?: boolean;
/**
@@ -169,6 +170,7 @@ function readRuntime(value: Record<string, unknown>): BootRuntime {
return {
apiPrefix: readString(value.apiPrefix),
webSocketPath: readString(value.webSocketPath),
bootId: readNonEmptyString(value.bootId),
lspAutoInstallPreferenceLanguages: readStringArray(value.lspAutoInstallPreferenceLanguages),
debug: value.debug === true ? true : undefined,
nonProduction: value.nonProduction === true ? true : undefined,
+3
View File
@@ -209,6 +209,9 @@
"navLogs": "Logs",
"navUpdates": "Updates",
"navUsers": "Users",
"backendReloadRequiredTitle": "Reload required",
"backendReloadRequiredBody": "Kandev restarted. Reload this page to continue. Reloading discards unsaved changes.",
"backendReloadRequiredAction": "Reload page",
"reloadPage": "Reload page",
"restartAction": "Restart",
"agentRuntimeUnavailableTitle": "Local agent runtime stopped",
+3
View File
@@ -209,6 +209,9 @@
"navLogs": "Ĺōĝś",
"navUpdates": "Ũƥďàţēś",
"navUsers": "Ũśēŕś",
"backendReloadRequiredTitle": "Ŕēĺōàď ŕēqũĩŕēď",
"backendReloadRequiredBody": "Ķàńďēv ŕēśţàŕţēď. Ŕēĺōàď ţĥĩś ƥàĝē ţō ćōńţĩńũē. Ŕēĺōàďĩńĝ ďĩśćàŕďś ũńśàvēď ćĥàńĝēś.",
"backendReloadRequiredAction": "Ŕēĺōàď ƥàĝē",
"reloadPage": "Ŕēĺōàď ƥàĝē",
"restartAction": "Ŕēśţàŕţ",
"agentRuntimeUnavailableTitle": "Ĺōćàĺ àĝēńţ ŕũńţĩḿē śţōƥƥēď",
+3
View File
@@ -204,6 +204,9 @@
"navLogs": "Registos",
"navUpdates": "Atualizações",
"navUsers": "Utilizadores",
"backendReloadRequiredTitle": "É necessário recarregar",
"backendReloadRequiredBody": "O Kandev foi reiniciado. Recarregue esta página para continuar. O recarregamento elimina as alterações não guardadas.",
"backendReloadRequiredAction": "Recarregar página",
"reloadPage": "Recarregar a página",
"restartAction": "Reiniciar",
"agentRuntimeUnavailableTitle": "O runtime local do agente parou",
+3
View File
@@ -206,6 +206,9 @@
"navLogs": "日志",
"navUpdates": "更新",
"navUsers": "用户",
"backendReloadRequiredTitle": "需要重新加载",
"backendReloadRequiredBody": "Kandev 已重启。请重新加载此页面以继续。重新加载会丢弃未保存的更改。",
"backendReloadRequiredAction": "重新加载页面",
"reloadPage": "重新加载页面",
"restartAction": "重启",
"agentRuntimeUnavailableTitle": "本地智能体运行时已停止",
+3
View File
@@ -206,6 +206,9 @@
"navLogs": "日誌",
"navUpdates": "更新",
"navUsers": "用戶",
"backendReloadRequiredTitle": "需要重新載入",
"backendReloadRequiredBody": "Kandev 已重啓。請重新載入此頁面以繼續。重新載入會丟棄未儲存的更改。",
"backendReloadRequiredAction": "重新載入頁面",
"reloadPage": "重新載入頁面",
"restartAction": "重啓",
"agentRuntimeUnavailableTitle": "本地代理程式執行時已停止",
+3
View File
@@ -206,6 +206,9 @@
"navLogs": "日誌",
"navUpdates": "更新",
"navUsers": "使用者",
"backendReloadRequiredTitle": "需要重新載入",
"backendReloadRequiredBody": "Kandev 已重啟。請重新載入此頁面以繼續。重新載入會丟棄未儲存的更改。",
"backendReloadRequiredAction": "重新載入頁面",
"reloadPage": "重新載入頁面",
"restartAction": "重啟",
"agentRuntimeUnavailableTitle": "本地代理程式執行時已停止",
+5
View File
@@ -2,6 +2,8 @@ import { StrictMode, useEffect } from "react";
import { createRoot } from "react-dom/client";
import "@/app/globals.css";
import { setOnUnauthorized } from "@/lib/api/client";
import { scheduleFrontendErrorReport } from "@/lib/api/domains/frontend-error-log-api";
import { setBackendReloadDiagnosticReporter } from "@/lib/platform/backend-reload-coordinator";
import { useAppStoreApi, StateProvider } from "@/components/state-provider";
import { PluginBootBridge } from "@/lib/plugins/plugin-boot-bridge";
import { preloadLocale } from "@/lib/i18n";
@@ -20,6 +22,9 @@ import { applyTitlePrefix } from "@/lib/browser/document-title";
installVitePreloadRecovery();
installBfcacheRestoreReload();
markRenderingEngine(document.documentElement);
setBackendReloadDiagnosticReporter((source) => {
scheduleFrontendErrorReport({ source: "toast-provider", title: source });
});
const AUTH_ROUTE_PATHS = new Set(["/login", "/setup", "/invite"]);
@@ -1,6 +1,6 @@
# SPA Failure Containment and Deployment Recovery
**Status:** accepted
**Status:** accepted (amended 2026-09-03)
**Date:** 2026-07-27
**Area:** frontend
@@ -16,6 +16,10 @@ boundary. Settings and Office are lazy routes rendered with a null Suspense
fallback. An uncaught render or lazy-import error can empty the React root, and
a pending import can look like the same blank failure.
An application document can also remain open across a backend restart. The
document then has old boot data and an old settings interlock token. Waiting for
the next protected mutation exposes an internal recovery condition too late.
Two render-determinism defects compound this behavior:
- the Settings SPA adapter creates new thenables during render and passes them
@@ -76,6 +80,29 @@ insufficient because it leaves the old frontend module graph alive.
Desktop-native update behavior is unchanged.
### Backend-generation recovery
Each application document receives the backend `boot_id` in its boot payload.
After every successful WebSocket connection, the application shell requests
`/system/info` without cache and compares the live ID with the document ID.
- A different ID proves that the backend process changed. The current document
enters a one-way reload-required state.
- A disconnect, failed request, missing ID, or equal ID does not prove a
restart. It does not enter reload-required state.
- Every authenticated application route presents the same persistent in-flow
alert. The alert is not a toast and does not expire or allow dismissal.
- The user must select **Reload page**. Kandev does not reload automatically
because the current route can contain unsaved work.
- A typed stale-settings-interlock response enters the same state when a
protected mutation races with proactive detection.
- Intentional restart and self-update flows coordinate with this state. One
flow owns the reload action at a time, so the UI does not show duplicate
reload-required surfaces.
The reload keeps the current browser location. The new document receives the
new process identity and leaves reload-required state.
### Render determinism
- SPA route adapters pass synchronous parsed identifiers to client components.
@@ -96,6 +123,10 @@ Desktop-native update behavior is unchanged.
stops reloading and exposes an actionable fallback.
- Successful service updates replace the old HTML and JavaScript graph as soon
as the new backend is authoritative.
- An open application document identifies a changed backend process after its
WebSocket reconnects. It gives the user a clear recovery action before the
next mutation.
- Temporary network failures do not claim that Kandev restarted.
- Settings route adapters and Zustand selectors have explicit identity
contracts that are enforceable with focused tests.
- Error boundaries do not catch event-handler or arbitrary asynchronous
@@ -121,6 +152,27 @@ retry is not reliable.
Rejected. A missing or corrupt asset could create an infinite cross-document
reload loop.
### Reload automatically after every backend restart
Rejected. The open route can contain unsaved work. A persistent warning lets
the user copy that work before a full reload.
### Treat every WebSocket reconnect as a backend restart
Rejected. Temporary network failures also reconnect the WebSocket. A changed
`boot_id` is the required proof.
### Poll process identity on a fixed timer
Rejected. A backend restart already breaks and reconnects the shared WebSocket.
Checking identity after connection recovery gives prompt detection without
continuous background requests.
### Handle only the settings interlock rejection
Rejected. That response appears only after a protected settings mutation. It
does not protect other routes from continued use with old boot and route data.
### Retain multiple generations of embedded assets
Deferred. It increases packaging and asset-lifecycle complexity and does not
@@ -0,0 +1,134 @@
---
created: 2026-09-03
status: completed
requirements:
- REQ-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001
system_design:
- ../../specs/platform/system-design/backend-restart-page-recovery.md
legacy_specs: []
---
# Implementation Plan: Backend Restart Page Recovery
## Overview
Publish the backend process identity in each application boot payload. Detect a
changed process after WebSocket connection recovery. Then show one shared reload
alert across every authenticated application route.
Keep the typed agent settings interlock response as a race fallback. Coordinate
the new global state with existing restart and self-update flows.
## Scope
### In scope
- Add the current `boot_id` to HTML and `/api/v1/app-state` boot payloads.
- Check system identity after each successful WebSocket connection.
- Latch one document-local reload-required state after a proven change.
- Show one persistent in-flow alert on all authenticated application routes.
- Add a stable stale-interlock error code and route it to the same state.
- Coordinate intentional restart and self-update reload ownership.
- Add localized desktop, tablet, and phone behavior.
- Add unit, integration, and real-restart E2E coverage.
### Out of scope
- Automatic reload or unsaved-draft persistence.
- Timer-based identity polling.
- Changes to WebSocket retry policy or the public health endpoint.
- Changes to the interim settings interlock threat model.
- Alerts on login or setup screens outside the application shell.
## Technical approach
### Boot identity
- Add optional `bootId` to the backend and frontend boot runtime shapes.
- Source it from the existing process-scoped `info.Service`.
- Include it in both server-rendered HTML and `/api/v1/app-state` responses.
- Extend payload and parser tests before implementation.
### Detection and fallback
- Add a document-local reload coordinator with a one-way state transition.
- Observe shared WebSocket connection transitions in the application shell.
- On each `connected` transition, fetch system information with `no-store`.
- Compare the response with the original boot ID. Ignore failures, missing IDs,
equal IDs, and responses from older connection checks.
- Add `interim_settings_interlock_required` to interlock rejections.
- Make the shared API client signal the coordinator for that exact code.
- Suppress only the local error that the coordinator handled.
### Global recovery UI
- Render the alert in `AppStatusSurfaceProvider` above route content.
- Use a persistent `role="alert"` surface with one explicit reload action.
- Keep the current route usable so the user can copy unsaved work.
- Register intentional restart ownership in `useKandevRestart` and
`useSelfUpdate`. Preserve their current progress and error surfaces.
- Add all copy to the six locale catalogs. Generate the Traditional Chinese
catalogs with `pnpm run i18n:zh-hant`.
## Tests
- **AC 001.1:** Backend payload tests and frontend parser tests cover valid,
missing, and malformed boot IDs.
- **AC 001.2 through 001.4:** Guard tests cover initial connection, reconnect,
failed requests, same IDs, changed IDs, and stale responses.
- **AC 001.5 through 001.8:** Coordinator and alert tests cover the one-way
latch, client-side navigation, deduplication, explicit reload, and no
automatic reload.
- **AC 001.9 and 001.11:** Middleware and API client tests cover the stable code,
unrelated HTTP 403 responses, and local-error suppression.
- **AC 001.10:** Restart and self-update hook tests cover reload ownership and
the single-surface rule.
- **AC 001.12:** Component and browser tests cover keyboard use, phone touch
size, overflow, and navigation clearance.
## E2E tests
- `apps/web/e2e/tests/layout/backend-restart-page-recovery.spec.ts` opens a
non-settings route in the `chromium` project. It restarts the real backend,
waits for WebSocket recovery, and checks the alert before any user action.
It selects **Reload page** and verifies the fresh document.
- `apps/web/e2e/tests/layout/mobile-backend-restart-page-recovery.spec.ts`
repeats the flow in the `mobile-chrome` project. It also checks the action
height, the single scroll owner, horizontal containment, and navigation
clearance.
The E2E tests seed through API fixtures. They do not simulate a process change
with request interception.
## Work orders
- [completed] [Task 01: Publish page boot identity](task-01-publish-page-boot-identity.md)
- [completed] [Task 02: Detect changed backend generation](task-02-detect-backend-generation.md)
- [completed] [Task 03: Show the global reload alert](task-03-show-global-reload-alert.md)
## Verification results
Implementation and verification passed:
- `make -C apps/backend test`: all backend packages passed.
- Focused backend interlock tests: 154 tests passed.
- Focused frontend recovery, agent-settings, and lifecycle suites: 81 tests
passed across 12 files.
- `pnpm run lint`, `pnpm run typecheck`, and `pnpm run i18n:check`: passed.
- `python3 scripts/lint-spec-files.py --all` and `git diff --check`: passed.
- `make build-backend build-web`: passed.
- Managed desktop and mobile real-backend restart E2E tests: one test passed
in each project, including explicit reload and mobile layout checks.
## Risks
- A network reconnect can look like a restart. The guard must require a changed
boot ID.
- An older identity response can arrive after a newer connection. The guard
must reject stale response sequences.
- Intentional restart dialogs can duplicate the global alert. Reload ownership
must have one active surface and a global fallback.
- A broad HTTP 403 match can hide authorization errors. The API client must
require the stable interlock code.
- A reload discards draft data. The product must warn the user and wait for an
explicit action.
@@ -0,0 +1,86 @@
---
id: "01-publish-page-boot-identity"
title: "Publish page boot identity"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001
acceptance_criteria:
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.1
system_design:
- ../../specs/platform/system-design/backend-restart-page-recovery.md
---
# Task 01: Publish Page Boot Identity
## Summary
Add the existing backend process identity to the application boot payload. Parse
it as the original document identity in the frontend.
## In scope
- Add `bootId` to backend and frontend runtime payload shapes.
- Source the value from the existing `info.Service` instance.
- Cover HTML boot and `/api/v1/app-state` boot paths.
- Accept a missing field for compatibility with partial fixtures.
- Add tests with a TDD red phase.
## Out of scope
- Identity comparison and WebSocket observation.
- The visible reload alert.
- Changes to `GET /health` or boot ID generation.
## Acceptance
- Each boot path returns the same process ID as `/api/v1/system/info`.
- The frontend parser keeps a valid non-empty ID.
- Missing or malformed values do not break application boot.
## Verification
```bash
make -C apps/backend test
cd apps && pnpm --filter @kandev/web test -- --run src/boot-payload.test.ts
```
## Files likely touched
- `apps/backend/internal/webapp/payload.go`
- `apps/backend/internal/backendapp/helpers.go`
- `apps/backend/internal/backendapp/helpers_test.go`
- `apps/web/src/boot-payload.ts`
- `apps/web/src/boot-payload.test.ts`
## Dependencies
None.
## Risks
- HTML boot and API fallback payloads must use the same service instance.
- A required frontend field breaks older fixtures and partial boot tests.
## Parallelism
`sequential`
## Inputs
- Acceptance criterion 001.1.
- System-design section `Identity contract`.
- Existing `info.Service.Info().BootID` process identity.
## Results
Implemented the process-scoped boot ID in the backend runtime payload and
frontend parser. Both the HTML and `/api/v1/app-state` paths use the shared
payload builder, while missing or malformed IDs remain optional.
Verification passed:
- `make -C apps/backend test`: all backend packages passed.
- `pnpm --filter @kandev/web test -- --run src/boot-payload.test.ts`: passed.
@@ -0,0 +1,104 @@
---
id: "02-detect-backend-generation"
title: "Detect changed backend generation"
status: done
wave: 2
depends_on:
- "01-publish-page-boot-identity"
plan: "plan.md"
requirements:
- REQ-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001
acceptance_criteria:
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.2
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.3
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.4
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.9
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.11
system_design:
- ../../specs/platform/system-design/backend-restart-page-recovery.md
---
# Task 02: Detect Changed Backend Generation
## Summary
Create the one-way reload coordinator. Signal it after a changed boot ID or an
exact stale-interlock response.
## In scope
- Add the document-local coordinator and subscription contract.
- Check `/api/v1/system/info` after each successful WebSocket connection.
- Ignore failed, missing, equal, and out-of-order identity results.
- Add the stable stale-interlock response code.
- Signal the coordinator from the shared API client for that exact code.
- Keep profile-delete HTTP 409 behavior through the shared response parser.
- Add backend and frontend tests with a TDD red phase.
## Out of scope
- The visible reload alert and locale copy.
- Timer-based polling or WebSocket retry changes.
- Changes to interlock token validation or route scope.
## Acceptance
- A successful connection check latches reload-required only for a changed ID.
- Repeated or late signals cannot clear the state or create duplicate reports.
- The stale-interlock code enters the same state and marks that error handled.
- Other HTTP 403 and connection failures keep their current behavior.
## Verification
```bash
go test ./apps/backend/internal/common/httpmw ./apps/backend/internal/agent/settings/handlers
cd apps && pnpm --filter @kandev/web test -- --run lib/platform/backend-reload-coordinator.test.ts hooks/domains/system/use-backend-generation-guard.test.ts lib/api/client.test.ts app/actions/agents.test.ts
```
## Files likely touched
- `apps/backend/internal/common/httpmw/interim_settings_interlock.go`
- `apps/backend/internal/common/httpmw/interim_settings_interlock_test.go`
- `apps/backend/internal/agent/settings/handlers/interim_settings_interlock_test.go`
- `apps/web/lib/platform/backend-reload-coordinator.ts`
- `apps/web/lib/platform/backend-reload-coordinator.test.ts`
- `apps/web/hooks/domains/system/use-backend-generation-guard.ts`
- `apps/web/hooks/domains/system/use-backend-generation-guard.test.ts`
- `apps/web/lib/api/client.ts`
- `apps/web/lib/api/client.test.ts`
- `apps/web/app/actions/agents.ts`
## Dependencies
- Task 01 supplies the document boot ID.
## Risks
- The connection state can change while an identity request is pending.
- The API client must not classify all HTTP 403 responses as stale documents.
- The profile delete action has a typed conflict result that must not change.
## Parallelism
`sequential`
## Inputs
- Acceptance criteria 001.2, 001.3, 001.4, 001.9, and 001.11.
- System-design sections `Detection flow`, `Stale-interlock fallback`, and
`Recovery coordinator`.
- Existing shared connection state and `fetchSystemInfo` API.
## Results
Implemented the document-local coordinator, connection recovery guard, typed
interlock code, shared API classification, and profile-delete response
handling. Protected agent-settings presenters suppress only errors marked as
handled by the coordinator.
Verification passed:
- `go test ./internal/common/httpmw ./internal/agent/settings/handlers`: 154
tests passed.
- Focused coordinator, generation-guard, API-client, action, and agent-settings
suites: passed.
@@ -0,0 +1,115 @@
---
id: "03-show-global-reload-alert"
title: "Show the global reload alert"
status: done
wave: 3
depends_on:
- "02-detect-backend-generation"
plan: "plan.md"
requirements:
- REQ-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001
acceptance_criteria:
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.5
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.6
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.7
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.8
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.9
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.10
- AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.12
system_design:
- ../../specs/platform/system-design/backend-restart-page-recovery.md
---
# Task 03: Show the Global Reload Alert
## Summary
Render one persistent reload alert above all application route content. Connect
intentional restart owners, suppress duplicate settings errors, and prove the
flow on desktop and phone.
## In scope
- Mount the generation guard and alert in the shared status surface provider.
- Add the explicit current-document reload action.
- Preserve route use until the user chooses to reload.
- Register reload ownership in generic restart and self-update hooks.
- Suppress local agent settings errors for a handled stale-interlock response.
- Add all six locale values.
- Add component, hook, and real-restart E2E tests with TDD.
## Out of scope
- Automatic reload and draft persistence.
- Alerts outside the authenticated application shell.
- A new toast, modal, route, drawer, or mobile-only workflow.
## Acceptance
- Every authenticated route shows the same non-dismissible in-flow alert after
a proven restart.
- Client-side navigation and repeated signals keep one alert.
- Intentional restart flows expose one reload-required surface.
- The action reloads only after user activation and keeps the current location.
- Phone layout provides a 44px action without overflow or covered navigation.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- --run components/app-status-bar/backend-reload-required-alert.test.tsx hooks/domains/system/use-kandev-restart.test.ts hooks/domains/system/use-self-update.test.ts
cd apps/web && pnpm run i18n:check && pnpm run typecheck
make build-backend build-web
cd apps/web && pnpm e2e:run --project=chromium tests/layout/backend-restart-page-recovery.spec.ts
cd apps/web && pnpm e2e:run --project=mobile-chrome tests/layout/mobile-backend-restart-page-recovery.spec.ts
```
## Files likely touched
- `apps/web/components/app-status-bar/backend-reload-required-alert.tsx`
- `apps/web/components/app-status-bar/backend-reload-required-alert.test.tsx`
- `apps/web/components/app-status-bar/app-status-surface-provider.tsx`
- `apps/web/hooks/domains/system/use-kandev-restart.ts`
- `apps/web/hooks/domains/system/use-kandev-restart.test.ts`
- `apps/web/hooks/domains/system/use-self-update.ts`
- `apps/web/hooks/domains/system/use-self-update.test.ts`
- current agent settings error presenters
- `apps/web/src/locales/*/system.json`
- `apps/web/e2e/tests/layout/backend-restart-page-recovery.spec.ts`
- `apps/web/e2e/tests/layout/mobile-backend-restart-page-recovery.spec.ts`
## Dependencies
- Task 02 supplies the coordinator and restart signals.
## Risks
- A modal restart flow can hide a second in-flow action. Ownership must keep one
reachable recovery surface.
- Agent settings use several local error presenters. Each protected mutation
needs the exact handled-error predicate.
- The alert must stay above route data without creating another scroll owner.
## Parallelism
`sequential`
## Inputs
- Acceptance criteria 001.5 through 001.10 and 001.12.
- System-design sections `Recovery coordinator`, `Alert surface`, and
`Responsive and accessible behavior`.
- Existing agent runtime alert and restart progress patterns.
## Results
Implemented the shared alert, explicit current-location reload action,
intentional restart and self-update ownership, localized copy, and desktop and
mobile real-restart coverage.
Verification passed:
- Alert and restart/self-update hook suites: 24 tests passed.
- `pnpm run lint`, `pnpm run typecheck`, and `pnpm run i18n:check`: passed.
- `make build-backend build-web`: passed.
- Managed `chromium` and `mobile-chrome` restart E2E suites: one test passed in
each project.
+2
View File
@@ -38,6 +38,7 @@ localization, feature toggles, health, and shared session recovery services.
- [Agentctl instance stop idempotency](requirements/agentctl-instance-stop.md)
- [Agent Runtime Availability](requirements/agent-runtime-availability.md)
- [Background Work Liveness](requirements/background-work-liveness.md)
- [Backend restart page recovery](requirements/backend-restart-page-recovery.md)
- [Bounded Task Status Delivery](requirements/bounded-task-status-delivery.md)
- [Viewport-bounded Session Delivery](requirements/viewport-bounded-session-delivery.md)
- [Shared Cron Loop Safety When Office Is Disabled](requirements/cron-office-disabled-safety.md)
@@ -76,6 +77,7 @@ localization, feature toggles, health, and shared session recovery services.
- [Agent process exit and stderr drain](system-design/agent-process-exit-drain.md)
- [Agentctl instance stop idempotency](system-design/agentctl-instance-stop.md)
- [Background Work Liveness](system-design/background-work-liveness.md)
- [Backend restart page recovery](system-design/backend-restart-page-recovery.md)
- [Browser console retention](system-design/browser-console-retention.md)
- [Bounded Task Status Delivery](system-design/bounded-task-status-delivery.md)
- [Viewport-bounded Session Delivery](system-design/viewport-bounded-session-delivery.md)
@@ -0,0 +1,125 @@
---
status: active
system: platform
created: 2026-09-03
owners:
- kandev
---
# Backend Restart Page Recovery Requirements
## Overview
An open Kandev document can outlive the backend process that created it. The
document then contains old boot data, cached route data, and an old agent
settings interlock token. A later action can fail with an internal error, or it
can use data that no longer matches the running backend.
Kandev must detect a confirmed backend restart before the next user action. It
must show one clear reload alert on every authenticated application route.
## Terminology
- **Backend generation:** One backend process lifetime, identified by its
`boot_id`.
- **Page generation:** The backend generation in the document boot payload.
- **Reload-required state:** A document state that starts after proof that the
backend generation changed. It ends only when the browser loads a new
document.
- **Application route:** An authenticated route inside the shared application
shell. Login, setup, and other routes outside that shell are not application
routes.
## Requirements
### REQ-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001: Detect and recover an old application document
**Intent:** Tell users to reload as soon as Kandev proves that an open document
belongs to an earlier backend process.
**User story:** As a Kandev user, I want an immediate reload alert after the
backend restarts. This alert stops me from using an old application document.
#### Acceptance criteria
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.1:** Each application
document shall receive the current backend `boot_id` in its boot payload.
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.2:** After each successful
WebSocket connection, the document shall request current system information
without using a cached response.
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.3:** If the returned
`boot_id` differs from the page generation, Kandev shall enter the
reload-required state without another user action.
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.4:** Kandev shall not enter
the reload-required state because of a disconnect, a failed identity request,
or a response with the same `boot_id`.
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.5:** Every application route
shall show one persistent, non-dismissible, in-flow reload alert while the
document is in the reload-required state. Client-side navigation shall not
hide or duplicate it.
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.6:** The alert shall state
that Kandev restarted and that the user must reload the page to continue. It
shall warn that the reload discards unsaved changes.
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.7:** The alert shall include
a **Reload page** action. Kandev shall not reload automatically.
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.8:** The reload action shall
reload the current browser location. The new document shall use the current
backend generation and clear the reload-required state.
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.9:** A protected agent
settings rejection with the stable stale-interlock code shall also enter the
same reload-required state. This fallback shall suppress the internal error
and duplicate operation errors.
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.10:** An intentional restart
or self-update flow shall use the same recovery state. Kandev shall show only
one reload-required surface when that flow already owns the reload action.
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.11:** Other authorization,
connectivity, and settings errors shall keep their current behavior.
- **AC-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001.12:** Desktop, tablet, and
phone layouts shall provide the same recovery outcome. The action shall
support keyboard use and a touch target of at least 44px on phone layouts.
The alert shall not cause horizontal overflow or cover navigation.
## User-visible contract
The English alert uses this copy:
- Title: **Reload required**
- Body: **Kandev restarted. Reload this page to continue. Reloading discards
unsaved changes.**
- Action: **Reload page**
All visible copy is localized. Diagnostic details and the internal interlock
message do not appear in this alert.
## Scenarios
- **GIVEN** an application document from the current backend, **WHEN** its
WebSocket reconnects to the same process, **THEN** no reload alert appears.
- **GIVEN** an open application document, **WHEN** the backend restarts and the
WebSocket reconnects, **THEN** the reload alert appears before another user
action.
- **GIVEN** a system information request fails, **WHEN** Kandev has no process
identity proof, **THEN** only the existing connectivity behavior applies.
- **GIVEN** the reload alert is visible, **WHEN** the user changes application
routes, **THEN** the same alert remains visible.
- **GIVEN** a profile save races with restart detection, **WHEN** the backend
returns the stale-interlock code, **THEN** the same reload alert appears. The
internal message stays hidden.
- **GIVEN** the reload alert is visible, **WHEN** the user selects **Reload
page**, **THEN** the browser reloads the current location. The new document
does not contain the alert.
## Out of scope
- Automatic reload after a backend restart.
- Persistence or restoration of unsaved form data.
- A restart alert based only on a network failure or WebSocket disconnect.
- Changes to the security claims, token transport, or route scope of the
interim settings interlock.
- Backend process history or restart metrics.
## Related requirements
- [Agent Runtime Availability](agent-runtime-availability.md)
- [Session subscription recovery](session-subscription-recovery.md)
- [Semantic Notifications](notifications.md)
@@ -0,0 +1,226 @@
---
status: current
system: platform
requirements:
- REQ-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001
created: 2026-09-03
updated: 2026-09-03
owners:
- kandev
---
# Backend Restart Page Recovery System Design
## Purpose and boundaries
This design detects when an authenticated application document belongs to an
earlier backend process. It then presents one explicit reload path across all
application routes.
The backend owns process identity. The shared application shell owns detection
and recovery state. Agent settings keep a typed stale-interlock fallback for the
race before proactive detection completes.
This design does not change WebSocket retry policy, the public health endpoint,
or the interim settings interlock security model.
## Requirement mapping
| Requirement | Design sections |
| --- | --- |
| `REQ-PLATFORM-BACKEND-RESTART-PAGE-RECOVERY-001` | [Identity contract](#identity-contract), [Detection flow](#detection-flow), [Recovery coordinator](#recovery-coordinator), [Alert surface](#alert-surface), [Testing strategy](#testing-strategy) |
## Identity contract
`info.Service` already creates one random `boot_id` for each backend process.
It returns that value from `GET /api/v1/system/info`.
The backend adds the same value to `BootPayload.Runtime` as `bootId`. The HTML
boot payload and the `/api/v1/app-state` fallback use the same source. The
frontend parser accepts the field as optional for compatibility with an older
payload or a partial test fixture.
The boot ID is process identity, not a credential. It contains no interlock
token, session identifier, or user data. This change does not add fields to
`GET /health`.
## Components and responsibilities
- `webapp.RuntimeConfig` transports the document's initial `bootId`.
- `fetchSystemInfo({ cache: "no-store" })` obtains the current backend identity.
- `BackendGenerationGuard` observes successful WebSocket connections from the
shared application state. It compares the live ID with the boot payload ID.
- `backend-reload-coordinator` is a small external store. It latches the
reload-required state, deduplicates signals, and coordinates intentional
restart owners.
- `BackendReloadRequiredAlert` renders the global in-flow recovery surface.
- The shared API client recognizes the stable interlock error code and signals
the same coordinator.
- Agent settings presenters suppress a local error only when the coordinator
handled that exact stale-interlock error.
The guard and alert mount in `AppStatusSurfaceProvider`, above route content.
They remain independent of the optional application status bar. This location
also keeps the alert visible when route data is empty or stale.
## Detection flow
1. The backend creates one `boot_id` and includes it in the document boot
payload.
2. The application shell starts its normal WebSocket connection.
3. Each transition to `connected` starts one no-cache system information
request. A sequence guard ignores an older response after a later connection.
4. If the request fails or returns no ID, the guard records no restart. The
existing connectivity UI remains responsible for network state.
5. If the live ID equals the document boot ID, the guard records no restart.
6. If the IDs differ, the coordinator permanently latches reload-required for
the current document.
7. The alert becomes visible without waiting for a mutation or route change.
The comparison always uses the original document boot ID. It does not replace
the baseline after reconnect. This rule prevents a stale document from becoming
current without a full reload.
If the boot payload has no valid ID, proactive comparison is unavailable. The
typed stale-interlock response remains the recovery fallback for protected
agent settings.
## Stale-interlock fallback
The middleware response keeps HTTP 403 and adds this stable field:
```json
{
"error": "interim settings interlock required",
"error_code": "interim_settings_interlock_required"
}
```
The frontend matches only `error_code`. It does not match the English `error`
value or all HTTP 403 responses.
The shared API client signals the coordinator after it parses the response and
before it rejects the caller promise. It marks that `ApiError` as handled.
Agent settings presenters use the marker to prevent an internal or duplicate
error message. Agent profile deletion joins the shared response parser while it
keeps its typed HTTP 409 conflict result.
## Recovery coordinator
The coordinator has document-local state with these values:
- `current`: no changed backend generation is proven.
- `reload-required`: a changed generation or stale-interlock response is proven.
The transition to `reload-required` is one-way in the current document. Repeated
identity mismatches and interlock errors do not create new alerts or reports.
An intentional restart flow can register itself as the active reload owner
before it sends the restart request. The global alert stays hidden while that
flow presents its own blocking progress or reload surface. When the owner
unmounts without reloading, the global alert becomes the fallback. A self-update
keeps ownership through its existing controlled document reload.
The generic `useKandevRestart` flow and the self-update flow use this ownership
contract. They do not maintain a second backend-generation decision. Their
existing progress, error, and completion behavior stays intact.
## Alert surface
When no intentional restart owner is active, `BackendReloadRequiredAlert`
renders at the top of the route-content column. It is a persistent
`role="alert"` region, not a toast or modal.
The alert is non-dismissible. It does not block the current route, so a user can
copy unsaved work before reloading. Client-side route changes keep the same
coordinator and alert instance.
The **Reload page** action calls `window.location.reload()`. This preserves the
current path, query, and fragment. The next document receives the new boot ID
and starts in `current` state.
The alert and the existing connectivity warning can appear together. They
report different facts. Connectivity reports reachability. The reload alert
reports a proven process-generation change.
## Persistence and failure handling
The coordinator does not use local storage, session storage, or backend
persistence. The current document is the scope of the stale state.
A failed system information request cannot prove a restart. It does not show a
reload alert. The next successful WebSocket connection starts another check.
If the coordinator is not installed during early application startup, the
stale-interlock `ApiError` uses localized reload guidance at its caller. The raw
backend error does not become user-visible.
## Security
The system information request uses the existing authenticated endpoint and
credentials. The boot ID is safe browser-facing process metadata.
The interlock keeps its current constant-time comparison, bearer rejection,
route scope, fail-closed behavior, and token transport. The new error code does
not expose the token.
## Observability
The coordinator sends one frontend diagnostic report when a document first
enters reload-required state. The report includes the signal source and no
token value. Valid sources are `boot_id_changed` and
`settings_interlock_rejected`.
Backend logs keep the current HTTP request and restart evidence. This design
does not add a metric or durable event.
## Responsive and accessible behavior
Desktop and tablet show the alert in flow at the top of the route-content
column. Phone uses the same alert. Its text and action stack vertically.
The phone action fills the available width and has a minimum height of 44px. At
larger breakpoints, the action can use the compact button size. The alert stays
inside the existing scroll layout and does not cover bottom navigation or safe
areas.
The alert has a textual title, explanation, and button. It uses `role="alert"`
and does not rely on color. The button supports normal keyboard focus and
activation.
## Internationalization
The title, explanation, warning, and action exist in `en`, `pseudo`, `pt-pt`,
`zh-cn`, `zh-hk`, and `zh-tw`. The copy uses no Unicode em dash.
## Testing strategy
Backend boot payload tests assert that HTML and `/api/v1/app-state` expose the
same `bootId` as `/api/v1/system/info`. Existing system information tests keep
the within-process stability guarantee.
Frontend boot parser tests cover valid, missing, and malformed boot IDs. Guard
tests cover initial connection, reconnect, out-of-order responses, same-ID
responses, changed IDs, failed requests, and unmount cleanup.
Coordinator and alert tests cover the one-way latch, deduplication, route
survival, explicit reload, intentional restart ownership, localized copy, and
the no-coordinator fallback.
Backend and API client tests cover the stable stale-interlock error code. They
also cover unrelated HTTP 403 responses and profile-delete HTTP 409 conflicts.
Desktop Playwright coverage restarts a real test backend while a non-settings
route is open. It checks that the alert appears after reconnect and before
another user action. It then selects the reload action and checks that the new
document has no alert.
Mobile Playwright coverage repeats the real restart flow. It also checks the
44px action height, one scroll owner, horizontal containment, and navigation
clearance.
## Related decisions
- [SPA failure containment and deployment recovery](../../../decisions/2026-07-27-spa-failure-containment-and-deployment-recovery.md)
- [Settings route save coordinator](../../../decisions/0046-settings-route-save-coordinator.md)
- [Go-served single-page application](../../../decisions/0021-go-served-single-page-application.md)