fix(site): show startup script failure message without restart suggestion (#24449)

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 <noreply@anthropic.com>
This commit is contained in:
Jeremy Ruppel
2026-04-21 10:40:32 -04:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent ef2b3a7263
commit b4eb0e20e5
7 changed files with 235 additions and 37 deletions
@@ -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,
+46 -10
View File
@@ -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<AgentRowProps> = ({
template,
onUpdateAgent,
initialMetadata,
agentScriptTimings,
}) => {
const { browser_only, workspace_external_agent } = useFeatureVisibility();
const appSections = organizeAgentApps(agent.apps);
@@ -141,6 +150,12 @@ export const AgentRow: FC<AgentRowProps> = ({
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<AgentRowProps> = ({
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<AgentRowProps> = ({
<div className={cn("px-4", hasStartupFeatures ? "pb-4" : "py-4")}>
{healthIssues.length > 0 && (
<div className="mb-4 flex flex-col gap-3">
{healthIssues.map((issue) => (
<AgentAlert
key={`${issue.title}-${issue.detail}`}
{...issue}
troubleshootingURL={agent.troubleshooting_url}
/>
))}
{healthIssues.map((issue) => {
const isStartError =
issue.title === agentScriptMessages.start_error.title;
const detail =
isStartError && failedStartTimings?.length ? (
<StartScriptFailureDetail
baseDetail={issue.detail}
timings={failedStartTimings}
/>
) : (
issue.detail
);
return (
<AgentAlert
key={`${issue.title}-${issue.detail}`}
{...issue}
detail={detail}
troubleshootingURL={agent.troubleshooting_url}
/>
);
})}
</div>
)}
{hasStartupFeatures && hasAnyLogs && (
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AgentAlert } from "./AgentAlert";
import { AgentAlert, StartScriptFailureDetail } from "./AgentAlert";
const meta: Meta<typeof AgentAlert> = {
title: "pages/WorkspacePage/AgentAlert",
@@ -50,3 +50,34 @@ export const WithoutTroubleshootingURL: Story = {
troubleshootingURL: undefined,
},
};
export const WithScriptTimingDetail: Story = {
render: (args) => (
<AgentAlert
{...args}
detail={
<StartScriptFailureDetail
baseDetail="A startup script exited with an error. Check the agent logs for details."
timings={[
{
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: "test-agent-id",
workspace_agent_name: "test-agent",
},
]}
/>
}
/>
),
args: {
title: "Startup script failed",
severity: "warning",
prominent: false,
troubleshootingURL: undefined,
},
};
+26 -2
View File
@@ -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<StartScriptFailureDetailProps> = ({
baseDetail,
timings,
}) => {
return (
<>
{baseDetail}
<ul className="mt-2 mb-0 pl-0 list-none space-y-0.5">
{timings.map((t) => (
<li key={t.display_name} className="font-mono text-xs">
&ldquo;{t.display_name}&rdquo; exited with code {t.exit_code}
</li>
))}
</ul>
</>
);
};
export const AgentAlert: FC<AgentAlertProps> = ({
title,
detail,
@@ -219,6 +219,7 @@ export const Workspace: FC<WorkspaceProps> = ({
)}
workspace={workspace}
template={template}
agentScriptTimings={timings?.agent_script_timings}
onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated
/>
))}
@@ -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();
});
},
};
@@ -90,28 +90,47 @@ export const WorkspaceNotifications: FC<WorkspaceNotificationsProps> = ({
!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 && (
<NotificationActionButton onClick={onRestartWorkspace}>
Restart
</NotificationActionButton>
)}
{troubleshootingURL && (
<NotificationActionButton
onClick={() => window.open(troubleshootingURL, "_blank")}
>
Troubleshooting
</NotificationActionButton>
)}
</>
) : 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 ? (
<NotificationActionButton
onClick={() => window.open(troubleshootingURL, "_blank")}
>
Troubleshooting
</NotificationActionButton>
) : 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 && (
<NotificationActionButton onClick={onRestartWorkspace}>
Restart
</NotificationActionButton>
)}
{troubleshootingURL && (
<NotificationActionButton
onClick={() => window.open(troubleshootingURL, "_blank")}
>
Troubleshooting
</NotificationActionButton>
)}
</>
) : 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;
};