From b4eb0e20e5c61c7b2fac75f96e288d242bd3bd9d Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Tue, 21 Apr 2026 10:40:32 -0400 Subject: [PATCH] fix(site): show startup script failure message without restart suggestion (#24449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a workspace agent's startup script fails, restarting the workspace will not resolve the issue since the script will keep failing. Previously all unhealthy workspaces showed the same generic notification with a Restart button regardless of cause. Now, when every failing agent has `lifecycle_state=start_error`, the workspace-level notification shows "A startup script has failed" and guides the user to contact their template admin instead of offering a Restart action. > Code written by Claude 🤖 reviewed by yours truly --------- Co-authored-by: Claude Sonnet 4.6 --- .../modules/resources/AgentRow.stories.tsx | 61 ++++++++++++++ site/src/modules/resources/AgentRow.tsx | 56 ++++++++++--- .../WorkspacePage/AgentAlert.stories.tsx | 33 +++++++- site/src/pages/WorkspacePage/AgentAlert.tsx | 28 ++++++- site/src/pages/WorkspacePage/Workspace.tsx | 1 + .../WorkspaceNotifications.stories.tsx | 9 +- .../WorkspaceNotifications.tsx | 84 ++++++++++++++----- 7 files changed, 235 insertions(+), 37 deletions(-) diff --git a/site/src/modules/resources/AgentRow.stories.tsx b/site/src/modules/resources/AgentRow.stories.tsx index 460959ddf4..d614eefcc5 100644 --- a/site/src/modules/resources/AgentRow.stories.tsx +++ b/site/src/modules/resources/AgentRow.stories.tsx @@ -98,6 +98,12 @@ const installScriptLogSource: WorkspaceAgentLogSource = { display_name: "Install Script", }; +const startupScriptLogSource: WorkspaceAgentLogSource = { + ...M.MockWorkspaceAgentLogSource, + id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + display_name: "Startup Script", +}; + const tabbedLogs = [ { id: 100, @@ -229,6 +235,61 @@ export const StartError: Story = { }, }; +export const StartErrorWithTimings: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const scriptTab = await canvas.findByRole("tab", { + name: "Startup Script", + }); + await waitFor(() => + expect(scriptTab).toHaveAttribute("data-state", "active"), + ); + }, + args: { + agent: { + ...M.MockWorkspaceAgentStartError, + logs_length: 2, + log_sources: [startupScriptLogSource], + }, + agentScriptTimings: [ + { + display_name: "Startup Script", + exit_code: 1, + stage: "start", + status: "exit_failure", + started_at: "2021-05-05T00:00:00.000Z", + ended_at: "2021-05-05T00:00:01.000Z", + workspace_agent_id: M.MockWorkspaceAgentStartError.id, + workspace_agent_name: M.MockWorkspaceAgentStartError.name, + }, + ], + }, + parameters: { + webSocket: [ + { + event: "message", + data: JSON.stringify([ + { + id: 200, + level: "info", + output: "startup: preparing workspace", + source_id: M.MockWorkspaceAgentLogSource.id, + created_at: fixedLogTimestamp, + }, + { + id: 201, + level: "error", + output: "startup script: command not found", + source_id: startupScriptLogSource.id, + created_at: fixedLogTimestamp, + }, + ]), + }, + ], + }, +}; + export const ShuttingDown: Story = { args: { agent: M.MockWorkspaceAgentShuttingDown, diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index dc7572980e..eed5b58dc2 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -18,6 +18,7 @@ import { Link as RouterLink } from "react-router"; import AutoSizer from "react-virtualized-auto-sizer"; import type { FixedSizeList as List, ListOnScrollProps } from "react-window"; import type { + AgentScriptTiming, Template, Workspace, WorkspaceAgent, @@ -47,8 +48,14 @@ import { useKebabMenu } from "#/components/Tabs/utils/useKebabMenu"; import { useProxy } from "#/contexts/ProxyContext"; import { useClipboard } from "#/hooks/useClipboard"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; -import { getAgentHealthIssues } from "#/modules/workspaces/health"; -import { AgentAlert } from "#/pages/WorkspacePage/AgentAlert"; +import { + agentScriptMessages, + getAgentHealthIssues, +} from "#/modules/workspaces/health"; +import { + AgentAlert, + StartScriptFailureDetail, +} from "#/pages/WorkspacePage/AgentAlert"; import { AppStatuses } from "#/pages/WorkspacePage/AppStatuses"; import { cn } from "#/utils/cn"; import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps"; @@ -75,6 +82,7 @@ interface AgentRowProps { workspace: Workspace; template: Template; initialMetadata?: WorkspaceAgentMetadata[]; + agentScriptTimings?: readonly AgentScriptTiming[]; onUpdateAgent: () => void; } @@ -124,6 +132,7 @@ export const AgentRow: FC = ({ template, onUpdateAgent, initialMetadata, + agentScriptTimings, }) => { const { browser_only, workspace_external_agent } = useFeatureVisibility(); const appSections = organizeAgentApps(agent.apps); @@ -141,6 +150,12 @@ export const AgentRow: FC = ({ const hasStartupFeatures = Boolean(agent.logs_length); const healthIssues = getAgentHealthIssues(agent); const hasAgentIssues = healthIssues.length > 0; + const failedStartTimings = agentScriptTimings?.filter( + (t) => + t.workspace_agent_id === agent.id && + t.stage === "start" && + t.exit_code !== 0, + ); const { proxy } = useProxy(); const [showLogs, setShowLogs] = useState( (["starting", "start_timeout"].includes(agent.lifecycle_state) || @@ -220,7 +235,14 @@ export const AgentRow: FC = ({ agent, Boolean(hasDevcontainerErrors || shouldShowWildcardWarning), ); - const [selectedLogTab, setSelectedLogTab] = useState("all"); + const failedStartupScriptSource = hasAgentIssues + ? agent.log_sources.find( + (s) => s.display_name === STARTUP_SCRIPT_DISPLAY_NAME, + ) + : undefined; + const [selectedLogTab, setSelectedLogTab] = useState( + failedStartupScriptSource?.id ?? "all", + ); const sourceLogTabs = agent.log_sources .filter((logSource) => { // Remove the logSources that have no entries. @@ -482,13 +504,27 @@ export const AgentRow: FC = ({
{healthIssues.length > 0 && (
- {healthIssues.map((issue) => ( - - ))} + {healthIssues.map((issue) => { + const isStartError = + issue.title === agentScriptMessages.start_error.title; + const detail = + isStartError && failedStartTimings?.length ? ( + + ) : ( + issue.detail + ); + return ( + + ); + })}
)} {hasStartupFeatures && hasAnyLogs && ( diff --git a/site/src/pages/WorkspacePage/AgentAlert.stories.tsx b/site/src/pages/WorkspacePage/AgentAlert.stories.tsx index 464e7cb8ab..2f649609c5 100644 --- a/site/src/pages/WorkspacePage/AgentAlert.stories.tsx +++ b/site/src/pages/WorkspacePage/AgentAlert.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { AgentAlert } from "./AgentAlert"; +import { AgentAlert, StartScriptFailureDetail } from "./AgentAlert"; const meta: Meta = { title: "pages/WorkspacePage/AgentAlert", @@ -50,3 +50,34 @@ export const WithoutTroubleshootingURL: Story = { troubleshootingURL: undefined, }, }; + +export const WithScriptTimingDetail: Story = { + render: (args) => ( + + } + /> + ), + args: { + title: "Startup script failed", + severity: "warning", + prominent: false, + troubleshootingURL: undefined, + }, +}; diff --git a/site/src/pages/WorkspacePage/AgentAlert.tsx b/site/src/pages/WorkspacePage/AgentAlert.tsx index 3d40843c02..e47948c5a6 100644 --- a/site/src/pages/WorkspacePage/AgentAlert.tsx +++ b/site/src/pages/WorkspacePage/AgentAlert.tsx @@ -1,15 +1,39 @@ -import type { FC } from "react"; +import type { FC, ReactNode } from "react"; +import type { AgentScriptTiming } from "#/api/typesGenerated"; import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; import { Button } from "#/components/Button/Button"; interface AgentAlertProps { title: string; - detail: string; + detail: ReactNode; severity: "info" | "warning"; prominent: boolean; troubleshootingURL?: string; } +interface StartScriptFailureDetailProps { + baseDetail: string; + timings: readonly AgentScriptTiming[]; +} + +export const StartScriptFailureDetail: FC = ({ + baseDetail, + timings, +}) => { + return ( + <> + {baseDetail} +
    + {timings.map((t) => ( +
  • + “{t.display_name}” exited with code {t.exit_code} +
  • + ))} +
+ + ); +}; + export const AgentAlert: FC = ({ title, detail, diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index f1ca8fdb66..7845162ccf 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -219,6 +219,7 @@ export const Workspace: FC = ({ )} workspace={workspace} template={template} + agentScriptTimings={timings?.agent_script_timings} onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated /> ))} diff --git a/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.stories.tsx index 9fcc81fb16..2ad22e590d 100644 --- a/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.stories.tsx @@ -152,14 +152,19 @@ export const StartupScriptFailed: Story = { }, play: async ({ step }) => { - await step("activate hover trigger", async () => { + await step("shows startup script failure message", async () => { await userEvent.hover(screen.getByTestId("warning-notifications")); await waitFor(() => expect(screen.getByRole("tooltip")).toHaveTextContent( - /one or more workspace agents need attention/i, + /a startup script has failed/i, ), ); }); + await step("does not offer restart", async () => { + expect( + screen.queryByRole("button", { name: /restart/i }), + ).not.toBeInTheDocument(); + }); }, }; diff --git a/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx b/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx index 8d3b27f183..6147185813 100644 --- a/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceNotifications/WorkspaceNotifications.tsx @@ -90,28 +90,47 @@ export const WorkspaceNotifications: FC = ({ !workspace.health.healthy ) { const troubleshootingURL = findTroubleshootingURL(workspace.latest_build); - const hasActions = permissions.updateWorkspace || troubleshootingURL; - notifications.push({ - title: "One or more workspace agents need attention", - severity: "warning", - detail: "Expand an agent's logs to view per-agent health details.", - actions: hasActions ? ( - <> - {permissions.updateWorkspace && ( - - Restart - - )} - {troubleshootingURL && ( - window.open(troubleshootingURL, "_blank")} - > - Troubleshooting - - )} - - ) : undefined, - }); + + if (isStartupScriptFailure(workspace)) { + // Restarting won't fix a broken startup script, so omit the Restart + // button and guide the user to their template admin instead. + notifications.push({ + title: "A startup script has failed", + severity: "warning", + detail: + "The workspace agent is running but a startup script exited with an error.", + actions: troubleshootingURL ? ( + window.open(troubleshootingURL, "_blank")} + > + Troubleshooting + + ) : undefined, + }); + } else { + const hasActions = permissions.updateWorkspace || troubleshootingURL; + notifications.push({ + title: "One or more workspace agents need attention", + severity: "warning", + detail: "Expand an agent's logs to view per-agent health details.", + actions: hasActions ? ( + <> + {permissions.updateWorkspace && ( + + Restart + + )} + {troubleshootingURL && ( + window.open(troubleshootingURL, "_blank")} + > + Troubleshooting + + )} + + ) : undefined, + }); + } } // Dormant @@ -272,3 +291,24 @@ const findTroubleshootingURL = ( } return undefined; }; + +/** + * Returns true when every failing agent's lifecycle state is "start_error", + * meaning the agent process is running but a startup script exited with an + * error. Restarting the workspace will not fix this because the template admin + * must correct the startup script. + */ +const isStartupScriptFailure = (workspace: Workspace): boolean => { + const failingIds = new Set(workspace.health.failing_agents); + if (failingIds.size === 0) { + return false; + } + for (const resource of workspace.latest_build.resources) { + for (const agent of resource.agents ?? []) { + if (failingIds.has(agent.id) && agent.lifecycle_state !== "start_error") { + return false; + } + } + } + return true; +};