From 047cc432718a46c6a48d1abd76f3bb589883a3dd Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Thu, 23 Apr 2026 20:31:58 -0700 Subject: [PATCH] fix React reload and debug diagnostics --- apps/app/src/app/index.css | 113 ++++++ .../domains/session/chat/session-page.tsx | 31 +- .../session/surface/session-surface.tsx | 92 ++++- apps/app/src/react-app/kernel/system-state.ts | 68 +++- apps/app/src/react-app/shell/debug-logger.ts | 8 + .../react-app/shell/react-render-watchdog.ts | 101 ++++++ .../app/src/react-app/shell/session-route.tsx | 10 + .../src/react-app/shell/settings-route.tsx | 126 ++++++- apps/desktop/electron/runtime.mjs | 17 +- scripts/openwork-debug.sh | 326 +++++++++++++++++- 10 files changed, 835 insertions(+), 57 deletions(-) create mode 100644 apps/app/src/react-app/shell/react-render-watchdog.ts diff --git a/apps/app/src/app/index.css b/apps/app/src/app/index.css index 62773a79..7456d0d1 100644 --- a/apps/app/src/app/index.css +++ b/apps/app/src/app/index.css @@ -286,3 +286,116 @@ select:disabled { @utility animate-progress-shimmer { animation: progress-shimmer 2s infinite linear; } + +@keyframes ow-session-orbit { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes ow-session-orbit-reverse { + from { + transform: rotate(360deg); + } + to { + transform: rotate(0deg); + } +} + +@keyframes ow-session-comet { + 0%, + 100% { + opacity: 0.58; + transform: translate(-50%, -50%) rotate(0deg) translateX(42px) scale(0.72); + } + 50% { + opacity: 1; + transform: translate(-50%, -50%) rotate(180deg) translateX(42px) scale(1.08); + } +} + +@keyframes ow-session-glow { + 0%, + 100% { + opacity: 0.5; + transform: translate(-50%, -50%) scale(0.86); + } + 50% { + opacity: 1; + transform: translate(-50%, -50%) scale(1.12); + } +} + +@keyframes ow-session-scan { + 0% { + opacity: 0; + transform: translateY(-22px) scaleX(0.6); + } + 18%, + 72% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translateY(170px) scaleX(1); + } +} + +@keyframes ow-session-core { + from { + transform: rotate(0deg) scale(0.94); + } + 50% { + transform: rotate(180deg) scale(1.04); + } + to { + transform: rotate(360deg) scale(0.94); + } +} + +@keyframes ow-session-progress { + 0% { + transform: translateX(-120%); + } + 100% { + transform: translateX(240%); + } +} + +.ow-session-orbit { + animation: ow-session-orbit 3.8s linear infinite; +} + +.ow-session-orbit-reverse { + animation: ow-session-orbit-reverse 5.2s linear infinite; +} + +.ow-session-comet { + animation: ow-session-comet 2.4s cubic-bezier(0.45, 0, 0.2, 1) infinite; +} + +.ow-session-glow { + animation: ow-session-glow 3.2s ease-in-out infinite; +} + +.ow-session-scan { + animation: ow-session-scan 2.8s ease-in-out infinite; +} + +.ow-session-core { + animation: ow-session-core 2.6s ease-in-out infinite; +} + +.ow-session-progress { + animation: ow-session-progress 1.7s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .ow-session-wait * { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index 74e37528..00af629b 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -29,6 +29,7 @@ import { DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH, useWorkspaceShellLayout, } from "../../../shell/workspace-shell-layout"; +import { useReactRenderWatchdog } from "../../../shell/react-render-watchdog"; type StatusBarOverrides = Pick< StatusBarProps, @@ -196,6 +197,14 @@ export function SessionPage(props: SessionPageProps) { defaultLeftWidth: DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH, expandedRightWidth: 280, }); + useReactRenderWatchdog("SessionPage", { + selectedSessionId: props.selectedSessionId, + selectedWorkspaceId: props.selectedWorkspaceId, + clientConnected: props.clientConnected, + startupPhase: props.startupPhase, + hasSurface: Boolean(props.surface), + workspaceCount: props.workspaces.length, + }); const [renameOpen, setRenameOpen] = useState(false); const [renameTitle, setRenameTitle] = useState(""); @@ -442,15 +451,29 @@ export function SessionPage(props: SessionPageProps) { ) : null} {showDelayedSessionLoadingState ? ( -
-
-
- +
+
+
+
+
+ +
+
+
+
+
+
+
+
+

{t("session.loading_title")}

{t("session.loading_detail")}

+
+
+
) : null} diff --git a/apps/app/src/react-app/domains/session/surface/session-surface.tsx b/apps/app/src/react-app/domains/session/surface/session-surface.tsx index 45e73cab..ccb3e39a 100644 --- a/apps/app/src/react-app/domains/session/surface/session-surface.tsx +++ b/apps/app/src/react-app/domains/session/surface/session-surface.tsx @@ -24,6 +24,7 @@ import { import { getReactQueryClient } from "../../../infra/query-client"; import { ReactSessionComposer } from "./composer/composer"; import { DevProfiler } from "../../../shell/dev-profiler"; +import { useReactRenderWatchdog } from "../../../shell/react-render-watchdog"; import type { ReactComposerNotice } from "./composer/notice"; import { SessionDebugPanel } from "./debug-panel"; import { SessionTranscript } from "./message-list"; @@ -106,6 +107,40 @@ function useSharedQueryState(queryKey: readonly unknown[], fallback: T) { ); } +function messageHasVisibleAssistantOutput(message: UIMessage) { + if (message.role !== "assistant") return false; + return message.parts.some((part) => { + if ("text" in part && typeof part.text === "string") return part.text.trim().length > 0; + return part.type === "dynamic-tool" || part.type === "file"; + }); +} + +function AssistantWaitingCard() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
Thinking...
+
Waiting for the first response token
+
+
+
+
+
+
+ ); +} + function revokeAttachmentPreview(attachment: { previewUrl?: string | undefined }) { if (!attachment.previewUrl) return; URL.revokeObjectURL(attachment.previewUrl); @@ -120,6 +155,7 @@ export function SessionSurface(props: SessionSurfaceProps) { const [error, setError] = useState(null); const [sending, setSending] = useState(false); const [showDelayedLoading, setShowDelayedLoading] = useState(false); + const [awaitingAssistantBaseline, setAwaitingAssistantBaseline] = useState(null); const [rendered, setRendered] = useState<{ sessionId: string; snapshot: OpenworkSessionSnapshot } | null>(null); const [toolSkills, setToolSkills] = useState([]); const [toolMcpServers, setToolMcpServers] = useState([]); @@ -181,6 +217,7 @@ export function SessionSurface(props: SessionSurfaceProps) { setError(null); setSending(false); setShowDelayedLoading(false); + setAwaitingAssistantBaseline(null); // Clear draft + attachments + mentions on session change so typed text // doesn't bleed across sessions (and across workspaces). The sessionId // effectively changes when the workspace changes too because the route @@ -270,6 +307,23 @@ export function SessionSurface(props: SessionSurfaceProps) { const chatStreaming = sending || liveStatus.type === "busy" || liveStatus.type === "retry"; const renderedMessages = transcriptState ?? []; const pendingSessionLoad = !snapshot && snapshotQuery.isLoading && renderedMessages.length === 0; + const assistantOutputAfterAwaitStart = useMemo(() => { + if (awaitingAssistantBaseline === null) return false; + return renderedMessages + .slice(awaitingAssistantBaseline) + .some(messageHasVisibleAssistantOutput); + }, [awaitingAssistantBaseline, renderedMessages]); + const showAssistantWaitState = awaitingAssistantBaseline !== null && !assistantOutputAfterAwaitStart; + useReactRenderWatchdog("SessionSurface", { + sessionId: props.sessionId, + workspaceId: props.workspaceId, + messageCount: renderedMessages.length, + liveStatus: liveStatus.type, + sending, + pendingSessionLoad, + showAssistantWaitState, + hasSnapshot: Boolean(snapshot), + }); useEffect(() => { if (!pendingSessionLoad) { @@ -280,6 +334,17 @@ export function SessionSurface(props: SessionSurfaceProps) { return () => window.clearTimeout(id); }, [pendingSessionLoad]); + useEffect(() => { + if (awaitingAssistantBaseline === null) return; + if (assistantOutputAfterAwaitStart) { + setAwaitingAssistantBaseline(null); + return; + } + if (sending || liveStatus.type !== "idle" || renderedMessages.length <= awaitingAssistantBaseline) return; + const id = window.setTimeout(() => setAwaitingAssistantBaseline(null), 1200); + return () => window.clearTimeout(id); + }, [assistantOutputAfterAwaitStart, awaitingAssistantBaseline, liveStatus.type, renderedMessages.length, sending]); + const model = deriveSessionRenderModel({ intendedSessionId: props.sessionId, renderedSessionId: renderedMessages.length > 0 || snapshotQuery.data ? props.sessionId : rendered?.sessionId ?? null, @@ -336,6 +401,7 @@ export function SessionSurface(props: SessionSurfaceProps) { // talking" behavior that the Solid composer had. setError(null); setSending(true); + setAwaitingAssistantBaseline(renderedMessages.length); try { const nextDraft = buildDraft(text, attachments); await props.onSendDraft(nextDraft); @@ -346,6 +412,7 @@ export function SessionSurface(props: SessionSurfaceProps) { setSending(false); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : "Failed to send prompt."); + setAwaitingAssistantBaseline(null); setSending(false); } }; @@ -560,20 +627,23 @@ export function SessionSurface(props: SessionSurfaceProps) { {error || (snapshotQuery.error instanceof Error ? snapshotQuery.error.message : "Failed to load React session view.")}
- ) : renderedMessages.length === 0 && snapshot && snapshot.messages.length === 0 ? ( -
-
-
No transcript yet.
-
+ ) : renderedMessages.length === 0 && showAssistantWaitState ? ( +
+
+ ) : renderedMessages.length === 0 && snapshot && snapshot.messages.length === 0 ? ( + null ) : ( - scrollRef.current} - /> + <> + scrollRef.current} + /> + {showAssistantWaitState ? : null} + )}
diff --git a/apps/app/src/react-app/kernel/system-state.ts b/apps/app/src/react-app/kernel/system-state.ts index cb3145b3..eddc800d 100644 --- a/apps/app/src/react-app/kernel/system-state.ts +++ b/apps/app/src/react-app/kernel/system-state.ts @@ -31,8 +31,11 @@ export type ResetState = { export type SystemStateControls = { reload: ReloadState; + reloadCopy: { title: string; body: string }; markReloadRequired: (reason: ReloadReason, trigger?: ReloadTrigger) => void; clearReloadRequired: () => void; + reloadWorkspaceEngine: () => Promise; + canReloadWorkspaceEngine: boolean; reset: ResetState; openResetModal: (mode: ResetOpenworkMode) => void; closeResetModal: () => void; @@ -60,6 +63,9 @@ function clearOpenworkLocalStorage(mode: ResetOpenworkMode) { type UseSystemStateOptions = { hasActiveRuns: () => boolean; + reloadWorkspaceEngine?: () => Promise; + canReloadWorkspaceEngine?: () => boolean; + onReloadComplete?: () => void | Promise; setError: (message: string | null) => void; }; @@ -72,8 +78,8 @@ export function useSystemState( number | null >(null); const [reloadTrigger, setReloadTrigger] = useState(null); - const [reloadBusy] = useState(false); - const [reloadError] = useState(null); + const [reloadBusy, setReloadBusy] = useState(false); + const [reloadError, setReloadError] = useState(null); const [resetModalOpen, setResetModalOpen] = useState(false); const [resetModalMode, setResetModalMode] = @@ -111,8 +117,60 @@ export function useSystemState( setReloadPending(false); setReloadReasons([]); setReloadTrigger(null); + setReloadError(null); }, []); + const reloadCopy = useMemo(() => { + const title = t("system.reload_required"); + const bodyKey = + reloadReasons.length === 1 && reloadReasons[0] === "plugins" + ? "system.reload_body_plugins" + : reloadReasons.length === 1 && reloadReasons[0] === "skills" + ? "system.reload_body_skills" + : reloadReasons.length === 1 && reloadReasons[0] === "agents" + ? "system.reload_body_agents" + : reloadReasons.length === 1 && reloadReasons[0] === "commands" + ? "system.reload_body_commands" + : reloadReasons.length === 1 && reloadReasons[0] === "config" + ? "system.reload_body_config" + : reloadReasons.length === 1 && reloadReasons[0] === "mcp" + ? "system.reload_body_mcp" + : reloadReasons.length > 0 + ? "system.reload_body_mixed" + : "system.reload_body_default"; + return { title, body: t(bodyKey) }; + }, [reloadReasons]); + + const canReloadWorkspaceEngine = + !reloadBusy && options.canReloadWorkspaceEngine?.() !== false; + + const reloadWorkspaceEngine = useCallback(async () => { + if (reloadBusy) return; + if (options.canReloadWorkspaceEngine?.() === false) { + setReloadError(t("system.reload_unavailable")); + return; + } + setReloadBusy(true); + setReloadError(null); + options.setError(null); + try { + const ok = options.reloadWorkspaceEngine + ? await options.reloadWorkspaceEngine() + : false; + if (ok === false) { + setReloadError(t("system.reload_failed")); + return; + } + await options.onReloadComplete?.(); + clearReloadRequired(); + } catch (error) { + const message = error instanceof Error ? error.message : safeStringify(error); + setReloadError(message || t("system.reload_failed")); + } finally { + setReloadBusy(false); + } + }, [clearReloadRequired, options, reloadBusy]); + const openResetModal = useCallback( (mode: ResetOpenworkMode) => { if (options.hasActiveRuns()) { @@ -171,8 +229,11 @@ export function useSystemState( reloadBusy, reloadError, }, + reloadCopy, markReloadRequired, clearReloadRequired, + reloadWorkspaceEngine, + canReloadWorkspaceEngine, reset: { resetModalOpen, resetModalMode, @@ -192,7 +253,10 @@ export function useSystemState( markReloadRequired, openResetModal, options.setError, + reloadCopy, reloadBusy, + reloadWorkspaceEngine, + canReloadWorkspaceEngine, reloadError, reloadLastTriggeredAt, reloadPending, diff --git a/apps/app/src/react-app/shell/debug-logger.ts b/apps/app/src/react-app/shell/debug-logger.ts index 9dba3bb9..c1ddff6e 100644 --- a/apps/app/src/react-app/shell/debug-logger.ts +++ b/apps/app/src/react-app/shell/debug-logger.ts @@ -173,6 +173,14 @@ function enqueue(entry: DevLogEntry) { scheduleFlush(); } +export function recordDebugLog(entry: DevLogEntry) { + if (!started || !isEnabled()) { + recordInspectorEvent(`log.${entry.level}`, entry); + return; + } + enqueue(entry); +} + function isEnabled(): boolean { if (typeof window === "undefined") return false; // Always on in dev; explicit opt-out via `localStorage.openwork.debug.disableLogger = "1"`. diff --git a/apps/app/src/react-app/shell/react-render-watchdog.ts b/apps/app/src/react-app/shell/react-render-watchdog.ts new file mode 100644 index 00000000..0507c83b --- /dev/null +++ b/apps/app/src/react-app/shell/react-render-watchdog.ts @@ -0,0 +1,101 @@ +/** @jsxImportSource react */ +import { useEffect } from "react"; + +import { publishInspectorSlice, recordInspectorEvent } from "./app-inspector"; +import { recordDebugLog } from "./debug-logger"; + +type RenderWatchdogDetails = Record; + +type RenderWatchdogStats = { + name: string; + totalCommits: number; + windowCommits: number; + windowStartedAt: number; + lastCommitAt: number; + lastWarnAt: number; + lastDetails?: RenderWatchdogDetails; +}; + +const WINDOW_MS = 2_000; +const WARN_COMMIT_THRESHOLD = 40; +const WARN_COOLDOWN_MS = 5_000; +const statsByName = new Map(); +let inspectorInstalled = false; + +function compactStats() { + return Array.from(statsByName.values()) + .map((stats) => ({ + name: stats.name, + totalCommits: stats.totalCommits, + windowCommits: stats.windowCommits, + windowAgeMs: Math.max(0, Date.now() - stats.windowStartedAt), + lastCommitAgeMs: Math.max(0, Date.now() - stats.lastCommitAt), + lastWarnAgeMs: stats.lastWarnAt > 0 ? Math.max(0, Date.now() - stats.lastWarnAt) : null, + lastDetails: stats.lastDetails ?? null, + })) + .sort((a, b) => b.windowCommits - a.windowCommits || b.totalCommits - a.totalCommits); +} + +function installInspectorSlice() { + if (inspectorInstalled) return; + inspectorInstalled = true; + publishInspectorSlice("reactRenderWatchdog", () => ({ + windowMs: WINDOW_MS, + warnCommitThreshold: WARN_COMMIT_THRESHOLD, + components: compactStats(), + })); +} + +function recordCommit(name: string, details?: RenderWatchdogDetails) { + installInspectorSlice(); + const now = Date.now(); + let stats = statsByName.get(name); + if (!stats) { + stats = { + name, + totalCommits: 0, + windowCommits: 0, + windowStartedAt: now, + lastCommitAt: now, + lastWarnAt: 0, + }; + statsByName.set(name, stats); + } + + if (now - stats.windowStartedAt > WINDOW_MS) { + stats.windowStartedAt = now; + stats.windowCommits = 0; + } + + stats.totalCommits += 1; + stats.windowCommits += 1; + stats.lastCommitAt = now; + stats.lastDetails = details; + + if ( + stats.windowCommits >= WARN_COMMIT_THRESHOLD && + now - stats.lastWarnAt > WARN_COOLDOWN_MS + ) { + stats.lastWarnAt = now; + const payload = { + component: name, + windowCommits: stats.windowCommits, + windowMs: WINDOW_MS, + totalCommits: stats.totalCommits, + details: details ?? null, + }; + recordInspectorEvent("react.render_loop_suspected", payload); + recordDebugLog({ + level: "warn", + source: "react-render-watchdog", + message: "react.render_loop_suspected", + extra: payload, + }); + } +} + +export function useReactRenderWatchdog(name: string, details?: RenderWatchdogDetails) { + useEffect(() => { + recordCommit(name, details); + }); +} diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index e2287fbc..beee33bb 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -81,6 +81,7 @@ import { publishInspectorSlice, recordInspectorEvent, } from "./app-inspector"; +import { useReactRenderWatchdog } from "./react-render-watchdog"; import { getModelBehaviorSummary } from "../../app/lib/model-behavior"; import { filterProviderList, mapConfigProvidersToList } from "../../app/utils/providers"; import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork"; @@ -320,6 +321,15 @@ export function SessionRoute() { // behavior pill actually shows its options (bug: was empty before). const [providerCatalog, setProviderCatalog] = useState>>({}); const [openworkServerHostInfoState, setOpenworkServerHostInfoState] = useState(null); + useReactRenderWatchdog("SessionRoute", { + selectedSessionId, + selectedWorkspaceId, + loading, + workspaceCount: workspaces.length, + sessionGroupCount: Object.keys(sessionsByWorkspaceId).length, + commandPaletteOpen, + modelPickerOpen, + }); const [openworkServerSettingsVersion, setOpenworkServerSettingsVersion] = useState(0); const [routeEngineInfo, setRouteEngineInfo] = useState(null); const [shareRemoteAccessBusy, setShareRemoteAccessBusy] = useState(false); diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index a0c14656..b37e8292 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource react */ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Navigate, useLocation, useNavigate } from "react-router-dom"; import { SUGGESTED_PLUGINS } from "../../app/constants"; @@ -21,6 +21,7 @@ import { createOpenworkServerStore, useOpenworkServerStoreSnapshot } from "../do import { createProviderAuthStore, useProviderAuthStoreSnapshot } from "../domains/connections/provider-auth/store"; import ProviderAuthModal from "../domains/connections/provider-auth/provider-auth-modal"; import ConnectionsModals from "../domains/connections/modals"; +import { TopRightNotifications } from "../domains/shell-feedback/top-right-notifications"; import { GeneralSettingsView } from "../domains/settings/pages/general-view"; import { AdvancedView } from "../domains/settings/pages/advanced-view"; import { AppearanceView } from "../domains/settings/pages/appearance-view"; @@ -38,6 +39,7 @@ import { SettingsShell } from "../domains/settings/shell/settings-shell"; import { createAutomationsStore, useAutomationsStoreSnapshot } from "../domains/settings/state/automations-store"; import { createExtensionsStore, useExtensionsStoreSnapshot } from "../domains/settings/state/extensions-store"; import { usePlatform } from "../kernel/platform"; +import { useSystemState } from "../kernel/system-state"; import { useLocal } from "../kernel/local-provider"; import { DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH, @@ -65,6 +67,7 @@ import { ModelPickerModal } from "../domains/session/modals/model-picker-modal"; import type { ModelOption, ModelRef } from "../../app/types"; import { recordInspectorEvent } from "./app-inspector"; import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork"; +import { abortSessionSafe } from "../../app/lib/opencode-session"; type RouteWorkspace = OpenworkWorkspaceInfo & { displayNameResolved: string; @@ -192,6 +195,14 @@ function toSessionGroups( })); } +function isActiveSessionStatus(status: unknown) { + return status === "running" || status === "retry" || status === "busy"; +} + +function getSessionStatus(session: any) { + return session?.status ?? session?.state ?? session?.runStatus ?? null; +} + function parseSettingsPath(pathname: string): { tab: SettingsTab; redirectPath: string | null; @@ -375,6 +386,55 @@ export function SettingsRoute() { developerMode, }; + const activeReloadBlockingSessions = useMemo( + () => + Object.values(sessionsByWorkspaceId) + .flat() + .filter((session) => isActiveSessionStatus(getSessionStatus(session))) + .map((session: any) => ({ + id: String(session?.id ?? ""), + title: + String(session?.title ?? session?.slug ?? session?.id ?? "").trim() || + t("session.untitled"), + })) + .filter((session) => session.id.length > 0), + [sessionsByWorkspaceId], + ); + + const reloadWorkspaceEngineFromUi = useCallback(async () => { + const workspaceId = routeStateRef.current.runtimeWorkspaceId?.trim() || selectedWorkspaceId.trim(); + if (!openworkClient || !workspaceId) { + setRouteError(t("app.error_connect_first")); + return false; + } + + await openworkClient.reloadEngine(workspaceId); + + try { + window.dispatchEvent(new CustomEvent("openwork-server-settings-changed")); + } catch { + // ignore browser event dispatch failures + } + + return true; + }, [openworkClient, selectedWorkspaceId]); + + const systemState = useSystemState({ + hasActiveRuns: () => activeReloadBlockingSessions.length > 0, + reloadWorkspaceEngine: reloadWorkspaceEngineFromUi, + canReloadWorkspaceEngine: () => Boolean(openworkClient && (selectedWorkspace?.id || selectedWorkspaceId)), + setError: setRouteError, + }); + + const forceStopActiveSessionsAndReload = useCallback(async () => { + if (activeClient) { + for (const session of activeReloadBlockingSessions) { + await abortSessionSafe(activeClient, session.id).catch(() => undefined); + } + } + await systemState.reloadWorkspaceEngine(); + }, [activeClient, activeReloadBlockingSessions, systemState.reloadWorkspaceEngine]); + const shellLayout = useWorkspaceShellLayout({ expandedRightWidth: 320, defaultLeftWidth: DEFAULT_WORKSPACE_LEFT_SIDEBAR_WIDTH, @@ -414,8 +474,9 @@ export function SettingsRoute() { openworkServer: openworkServerStore, runtimeWorkspaceId: () => routeStateRef.current.runtimeWorkspaceId, developerMode: () => routeStateRef.current.developerMode, + markReloadRequired: systemState.markReloadRequired, }), - [openworkServerStore], + [openworkServerStore, systemState.markReloadRequired], ); const providerAuthStore = useMemo( () => @@ -435,9 +496,14 @@ export function SettingsRoute() { setDisabledProviders, markOpencodeConfigReloadRequired: () => { setConfigActionStatus(t("settings.config_updated")); + systemState.markReloadRequired("config", { + type: "config", + name: "opencode.json", + action: "updated", + }); }, }), - [openworkServerStore], + [openworkServerStore, systemState.markReloadRequired], ); const extensionsStore = useMemo( () => @@ -453,8 +519,9 @@ export function SettingsRoute() { setBusyLabel, setBusyStartedAt: () => {}, setError: setRouteError, + markReloadRequired: systemState.markReloadRequired, }), - [openworkServerStore], + [openworkServerStore, systemState.markReloadRequired], ); const automationsStore = useMemo( () => @@ -937,11 +1004,9 @@ export function SettingsRoute() { addPlugin={async () => { setRouteError("Scheduler plugin install is not wired into the React settings route yet."); }} - reloadWorkspaceEngine={async () => { - setRouteError("Workspace reload is not wired into the React settings route yet."); - }} - reloadBusy={false} - canReloadWorkspace={false} + reloadWorkspaceEngine={systemState.reloadWorkspaceEngine} + reloadBusy={systemState.reload.reloadBusy} + canReloadWorkspace={systemState.canReloadWorkspaceEngine} openLink={(url) => platform.openLink(url)} /> ); @@ -1065,12 +1130,10 @@ export function SettingsRoute() { updateOpenworkServerSettings: openworkServerStore.updateOpenworkServerSettings, resetOpenworkServerSettings: openworkServerStore.resetOpenworkServerSettings, testOpenworkServerConnection: openworkServerStore.testOpenworkServerConnection, - canReloadWorkspace: false, - reloadWorkspaceEngine: async () => { - setRouteError("Workspace reload is not wired into the React settings route yet."); - }, - reloadBusy: false, - reloadError: routeError, + canReloadWorkspace: systemState.canReloadWorkspaceEngine, + reloadWorkspaceEngine: systemState.reloadWorkspaceEngine, + reloadBusy: systemState.reload.reloadBusy, + reloadError: systemState.reload.reloadError ?? routeError, developerMode, }} /> @@ -1236,15 +1299,40 @@ export function SettingsRoute() { remoteSubmitting={createWorkspaceRemoteBusy} remoteError={createWorkspaceRemoteError} /> + 0 + ? t("app.reload_stop_tasks") + : t("app.reload_now") + } + dismissLabel={t("app.reload_later")} + reloadBusy={systemState.reload.reloadBusy} + canReload={systemState.canReloadWorkspaceEngine} + hasActiveRuns={activeReloadBlockingSessions.length > 0} + onReload={() => { + void (activeReloadBlockingSessions.length > 0 + ? forceStopActiveSessionsAndReload() + : systemState.reloadWorkspaceEngine()); + }} + onDismissReload={systemState.clearReloadRequired} + /> 0} + activeSessions={activeReloadBlockingSessions} isRemoteWorkspace={selectedWorkspace?.workspaceType === "remote"} - onForceStopSession={() => undefined} - onReloadEngine={() => undefined} + onForceStopSession={(sessionId) => { + if (!activeClient) return undefined; + return abortSessionSafe(activeClient, sessionId); + }} + onReloadEngine={systemState.reloadWorkspaceEngine} modalState={{ mcpAuthModalOpen: connectionsSnapshot.mcpAuthModalOpen, mcpAuthEntry: connectionsSnapshot.mcpAuthEntry, diff --git a/apps/desktop/electron/runtime.mjs b/apps/desktop/electron/runtime.mjs index 1831e3ae..08d3bc86 100644 --- a/apps/desktop/electron/runtime.mjs +++ b/apps/desktop/electron/runtime.mjs @@ -287,6 +287,17 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths const orchestratorState = createOrchestratorState(); const routerState = createRouterState(); + // Serialize engine lifecycle operations. Without this, concurrent renderer + // invocations of engineStart/engineStop/engineRestart race: each call's + // stopAllRuntimeChildren kills the previous call's freshly-spawned + // orchestrator daemon, and the prior call then times out its /health probe. + let runtimeLifecycleQueue = Promise.resolve(); + function withRuntimeLifecycle(fn) { + const next = runtimeLifecycleQueue.then(fn, fn); + runtimeLifecycleQueue = next.catch(() => {}); + return next; + } + const userDataDir = app.getPath("userData"); const sidecarDirs = [ path.join(desktopRoot, "src-tauri", "sidecars"), @@ -1526,9 +1537,9 @@ export function createRuntimeManager({ app, desktopRoot, listLocalWorkspacePaths } return { - engineStart, - engineStop, - engineRestart, + engineStart: (projectDir, options) => withRuntimeLifecycle(() => engineStart(projectDir, options)), + engineStop: () => withRuntimeLifecycle(() => engineStop()), + engineRestart: (options) => withRuntimeLifecycle(() => engineRestart(options)), engineInfo, engineInstall, openworkServerInfo, diff --git a/scripts/openwork-debug.sh b/scripts/openwork-debug.sh index 14020fe2..9a72e00f 100755 --- a/scripts/openwork-debug.sh +++ b/scripts/openwork-debug.sh @@ -9,18 +9,28 @@ # tail live tail pnpm dev + the /dev/log sink # sink print the dev log sink path # kill-orphans remove orphan openwork/opencode processes (ppid == launchd) +# diagnose-hang classify Electron crash/hang/sidecar/app-state failures # stop full, layered teardown of the dev stack (no cache wipe) # start launch pnpm dev in the background with the log sink on # wait-healthy block until openwork-server reports /health = 200 # reset stop + wipe Vite dep cache + truncate log sink + start # restart alias for reset # -# Teardown ordering (important): -# 1. pnpm dev (parent supervisor) -# 2. tauri dev (Rust dev runner, if still alive) -# 3. Tauri webview (target/debug/OpenWork-Dev) <-- never /Applications/ -# 4. Vite (node node_modules/.../vite) -# 5. orchestrator + openwork-server + opencode + opencode-router orphans +# Variant (OPENWORK_DEV_VARIANT): +# electron (default) pnpm --filter @openwork/desktop dev:electron +# Electron shell + CDP on 127.0.0.1:9823 for chrome-devtools MCP. +# Sidecars run from apps/desktop/src-tauri/sidecars/*. +# tauri legacy: pnpm dev (Tauri dev webview, no CDP). +# Sidecars run from apps/desktop/src-tauri/target/debug/*. +# +# Teardown ordering (important, both variants): +# 1. pnpm dev / dev:electron supervisor (parent supervisor) +# 2. tauri dev (Rust dev runner, if still alive) +# 3. Tauri webview (target/debug/OpenWork-Dev) <-- never /Applications/ +# 4. Electron main+helpers (node_modules/electron/...Electron.app) +# 5. Vite (node node_modules/.../vite) +# 6. orchestrator + openwork-server + opencode + opencode-router +# (both target/debug/* and src-tauri/sidecars/* trees) # # Cache/ephemeral state wiped by `reset`: # - Vite dep pre-bundle cache: apps/app/node_modules/.vite @@ -49,6 +59,17 @@ DEV_LOG_FILE="${OPENWORK_DEV_LOG_FILE:-$HOME/.openwork/debug/openwork-dev.log}" PNPM_DEV_LOG="${OPENWORK_PNPM_DEV_LOG:-/tmp/openwork-test/pnpm-dev.log}" PNPM_DEV_PID_FILE="${OPENWORK_PNPM_DEV_PID:-/tmp/openwork-test/pnpm-dev.pid}" WAIT_HEALTHY_SECS="${OPENWORK_WAIT_HEALTHY_SECS:-90}" +ELECTRON_CDP_PORT="${OPENWORK_ELECTRON_REMOTE_DEBUG_PORT:-9823}" + +# Dev variant. 'electron' (default) launches pnpm dev:electron with CDP on +# 127.0.0.1:9823 so chrome-devtools MCP can attach. 'tauri' preserves the +# legacy pnpm dev (Tauri webview) for users still on that path. +DEV_VARIANT="${OPENWORK_DEV_VARIANT:-electron}" +case "$DEV_VARIANT" in electron|tauri) ;; *) + printf '[openwork-debug] unknown OPENWORK_DEV_VARIANT=%s (expected electron|tauri)\n' "$DEV_VARIANT" >&2 + exit 2 + ;; +esac # --------------------------------------------------------------------------- # Helpers @@ -90,16 +111,92 @@ kill_pid_file() { } discover_openwork_server_port() { - ps -Ao command | grep "target/debug/openwork-server" | grep -v grep \ - | grep -oE '\-\-port [0-9]+' | head -1 | awk '{print $2}' + ps -Ao command \ + | grep -E "(target/debug|apps/desktop/src-tauri/sidecars)/openwork-server" \ + | grep -v grep \ + | grep -oE '\-\-port [0-9]+' \ + | head -1 \ + | awk '{print $2}' \ + || true +} + +electron_renderer_stats() { + ps -axo pid,ppid,pcpu,pmem,rss,command \ + | awk '/Electron Helper \(Renderer\)/ && /com\.differentai\.openwork/ && !/awk/ && !/grep/ {print; found=1} END {exit found ? 0 : 1}' \ + || true +} + +electron_renderer_pid() { + electron_renderer_stats | awk 'NR == 1 {print $1}' +} + +electron_renderer_cpu() { + electron_renderer_stats | awk 'NR == 1 {print $3}' +} + +probe_electron_page_cdp() { + node <<'NODE' +const port = process.env.OPENWORK_ELECTRON_REMOTE_DEBUG_PORT || "9823"; +const controller = new AbortController(); +const fail = (message) => { + console.error(message); + process.exit(1); +}; +const timeout = setTimeout(() => controller.abort(), 1800); +let targets; +try { + const response = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: controller.signal }); + targets = await response.json(); +} catch (error) { + clearTimeout(timeout); + fail(`target-list-failed: ${error instanceof Error ? error.message : String(error)}`); +} +clearTimeout(timeout); +const page = targets.find((target) => target.type === "page" && target.webSocketDebuggerUrl); +if (!page) fail("no-page-target"); + +const ws = new WebSocket(page.webSocketDebuggerUrl); +let settled = false; +const timer = setTimeout(() => { + if (settled) return; + settled = true; + try { ws.close(); } catch {} + fail("page-cdp-timeout"); +}, 2200); +ws.onopen = () => { + ws.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression: "1+1", returnByValue: true } })); +}; +ws.onerror = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + fail("page-cdp-websocket-error"); +}; +ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + if (message.id !== 1) return; + settled = true; + clearTimeout(timer); + try { ws.close(); } catch {} + if (message.error) fail(`page-cdp-error: ${message.error.message ?? JSON.stringify(message.error)}`); + console.log("ok"); + process.exit(0); + } catch (error) { + settled = true; + clearTimeout(timer); + fail(`page-cdp-parse-error: ${error instanceof Error ? error.message : String(error)}`); + } +}; +NODE } # --------------------------------------------------------------------------- # Public subcommands snapshot() { - echo "=== dev stack processes (target/debug tree) ===" - ps -Ao pid,ppid,command | awk '/target\/debug\/OpenWork-Dev|target\/debug\/openwork-server|target\/debug\/openwork-orchestrator|target\/debug\/opencode( |\/)|target\/debug\/opencode-router|vite|pnpm dev/ && !/awk/ && !/grep/' | sed -E 's#/Users/[^ ]*/#…/#g' | head -20 + echo "=== dev stack processes ===" + ps -Ao pid,ppid,command | awk '/target\/debug\/OpenWork-Dev|node_modules\/electron\/dist\/Electron\.app\/Contents\/MacOS\/Electron|apps\/desktop\/scripts\/electron-dev\.mjs|target\/debug\/openwork-server|target\/debug\/openwork-orchestrator|target\/debug\/opencode( |\/)|target\/debug\/opencode-router|apps\/desktop\/src-tauri\/sidecars\/openwork-server|apps\/desktop\/src-tauri\/sidecars\/openwork-orchestrator|apps\/desktop\/src-tauri\/sidecars\/opencode( |\/)|apps\/desktop\/src-tauri\/sidecars\/opencode-router|vite|pnpm .*dev/ && !/awk/ && !/grep/' | sed -E 's#/Users/[^ ]*/#…/#g' | head -20 echo echo "=== openwork-server ===" @@ -116,7 +213,13 @@ snapshot() { echo echo "=== opencode (via orchestrator) ===" local oc_port - oc_port=$(ps -Ao command | grep "target/debug/openwork-orchestrator" | grep -v grep | grep -oE '\-\-opencode-port [0-9]+' | head -1 | awk '{print $2}') + oc_port=$(ps -Ao command \ + | grep -E "(target/debug|apps/desktop/src-tauri/sidecars)/openwork-orchestrator" \ + | grep -v grep \ + | grep -oE '\-\-opencode-port [0-9]+' \ + | head -1 \ + | awk '{print $2}' \ + || true) if [[ -z "$oc_port" ]]; then echo " (no opencode port)" else @@ -128,7 +231,13 @@ snapshot() { echo echo "=== opencode-router ===" local r_port - r_port=$(ps -Ao command | grep "target/debug/opencode-router" | grep -v grep | grep -oE '\-\-opencode-url http://127.0.0.1:[0-9]+' | head -1 | awk '{print $2}') + r_port=$(ps -Ao command \ + | grep -E "(target/debug|apps/desktop/src-tauri/sidecars)/opencode-router" \ + | grep -v grep \ + | grep -oE '\-\-opencode-url http://127.0.0.1:[0-9]+' \ + | head -1 \ + | awk '{print $2}' \ + || true) if [[ -z "$r_port" ]]; then echo " (no opencode-router info)" else @@ -178,6 +287,148 @@ kill_orphans() { kill -9 $pids 2>/dev/null || true } +diagnose_hang() { + local now + now=$(date "+%Y-%m-%dT%H:%M:%S%z") + echo "=== openwork hang diagnosis ===" + echo "time=$now" + echo "repo=$REPO_ROOT" + echo "cdp=http://127.0.0.1:$ELECTRON_CDP_PORT" + echo + + echo "=== browser CDP ===" + local browser_json target_json + browser_json=$(curl -sS --max-time 2 "http://127.0.0.1:$ELECTRON_CDP_PORT/json/version" 2>&1 || true) + if [[ "$browser_json" == *"webSocketDebuggerUrl"* ]]; then + echo " browser: responsive" + printf '%s\n' "$browser_json" | sed -n '1,8p' + else + echo " browser: not reachable" + printf ' %s\n' "$browser_json" + fi + + echo + echo "=== page target ===" + target_json=$(curl -sS --max-time 2 "http://127.0.0.1:$ELECTRON_CDP_PORT/json/list" 2>&1 || true) + if [[ "$target_json" == *"webSocketDebuggerUrl"* ]]; then + printf '%s\n' "$target_json" | sed -n '1,20p' + else + echo " no page target (or target list failed)" + printf ' %s\n' "$target_json" + fi + + echo + echo "=== renderer process ===" + local renderer_stats renderer_pid renderer_cpu + renderer_stats=$(electron_renderer_stats) + renderer_pid=$(printf '%s\n' "$renderer_stats" | awk 'NR == 1 {print $1}') + renderer_cpu=$(printf '%s\n' "$renderer_stats" | awk 'NR == 1 {print $3}') + if [[ -n "$renderer_pid" ]]; then + echo " renderer: alive" + echo " PID PPID %CPU %MEM RSS COMMAND" + printf ' %s\n' "$renderer_stats" | sed -E 's#/Users/[^ ]*/#…/#g' + else + echo " renderer: missing" + fi + + echo + echo "=== page CDP probe ===" + local page_probe="skipped" + if [[ "$browser_json" == *"webSocketDebuggerUrl"* && "$target_json" == *"webSocketDebuggerUrl"* ]]; then + if page_probe=$(OPENWORK_ELECTRON_REMOTE_DEBUG_PORT="$ELECTRON_CDP_PORT" probe_electron_page_cdp 2>&1); then + echo " page: responsive ($page_probe)" + page_probe="ok" + else + echo " page: unresponsive ($page_probe)" + page_probe="failed" + fi + else + echo " page: skipped (browser or page target unavailable)" + fi + + echo + echo "=== classification ===" + local high_cpu="false" + if [[ -n "${renderer_cpu:-}" ]]; then + high_cpu=$(awk -v cpu="$renderer_cpu" 'BEGIN {print (cpu + 0 >= 80) ? "true" : "false"}') + fi + if [[ "$browser_json" != *"webSocketDebuggerUrl"* ]]; then + echo " no-electron-cdp: Electron is down or was not launched with CDP." + elif [[ "$target_json" == *"webSocketDebuggerUrl"* && -z "$renderer_pid" ]]; then + echo " renderer-crashed: browser CDP advertises a page but no renderer process exists." + echo " next: inspect latest macOS .ips crash report below." + elif [[ -n "$renderer_pid" && "$page_probe" == "failed" && "$high_cpu" == "true" ]]; then + echo " renderer-hung-hot: renderer exists, page CDP timed out, CPU >= 80%." + echo " next: use the sample captured below; logs will usually stop once wedged." + elif [[ -n "$renderer_pid" && "$page_probe" == "failed" ]]; then + echo " renderer-unresponsive: renderer exists but page CDP timed out." + echo " next: sample + check memory/CPU; could be blocked main thread or native stall." + elif [[ -n "$renderer_pid" && "$page_probe" == "ok" ]]; then + echo " renderer-responsive: likely app-state or sidecar/API issue, not a renderer hang." + else + echo " unknown: insufficient signal." + fi + + echo + echo "=== latest crash report ===" + local crash + crash=$(ls -t "$HOME"/Library/Logs/DiagnosticReports/*"Electron Helper (Renderer)"*.ips 2>/dev/null | head -1 || true) + if [[ -n "$crash" ]]; then + ls -la "$crash" + sed -n '1,55p' "$crash" 2>/dev/null | sed -n '1,25p' + else + echo " no Electron renderer .ips crash report found" + fi + + echo + echo "=== sidecar health ===" + local port + port=$(discover_openwork_server_port) + if [[ -n "$port" ]]; then + echo " openwork-server port=$port" + curl -sS --max-time 2 "http://127.0.0.1:$port/health" || echo "unreachable" + echo + else + echo " no openwork-server port discovered" + fi + + echo + echo "=== recent dev sink ===" + echo " path=$DEV_LOG_FILE" + if [[ -f "$DEV_LOG_FILE" ]]; then + ls -la "$DEV_LOG_FILE" + tail -40 "$DEV_LOG_FILE" + else + echo " missing" + fi + + echo + echo "=== recent pnpm log ===" + echo " path=$PNPM_DEV_LOG" + if [[ -f "$PNPM_DEV_LOG" ]]; then + ls -la "$PNPM_DEV_LOG" + tail -80 "$PNPM_DEV_LOG" + else + echo " missing" + fi + + echo + echo "=== renderer sample ===" + if [[ -n "$renderer_pid" && ( "$page_probe" == "failed" || "$high_cpu" == "true" ) ]]; then + local sample_file="/tmp/openwork-renderer-${renderer_pid}-$(date +%Y%m%dT%H%M%S).sample.txt" + if sample "$renderer_pid" 3 -file "$sample_file" >/dev/null 2>&1; then + echo " captured=$sample_file" + grep -n "Thread_.*CrRendererMain\|Call graph:\|Physical footprint" "$sample_file" | head -20 || true + else + echo " sample failed for pid=$renderer_pid" + fi + elif [[ -n "$renderer_pid" ]]; then + echo " skipped (renderer responsive and CPU not high). Set OPENWORK_FORCE_SAMPLE=1 not currently supported." + else + echo " skipped (no renderer process to sample)." + fi +} + # Ordered teardown. Safe to run when nothing is up; each step is idempotent. stop() { log "stopping dev stack (layered)" @@ -195,10 +446,20 @@ stop() { kill_by_pattern "tauri(-cli)? +dev" kill_by_pattern "@tauri-apps/cli" + # 2b. Electron variant supervisor (scripts/electron-dev.mjs). Idempotent if + # the current variant is tauri; harmless if nothing matches. + kill_by_pattern "apps/desktop/scripts/electron-dev\.mjs" + # 3. Tauri dev webview — match full path so the installed /Applications/ # prod bundle is never targeted. kill_by_pattern "target/debug/OpenWork-Dev" + # 3b. Electron main (the helpers die with the main via mach-port rendezvous + # loss; we still pattern-match them below in case a crash left orphans). + # Scoped to this repo's node_modules/electron so /Applications/Slack, + # Cursor, VSCode, etc. are never touched. + kill_by_pattern "node_modules/electron/dist/Electron\.app/Contents/MacOS/Electron" + # 4. Vite. Match the node process that loads the vite binary from this # repo's node_modules, not any arbitrary node process on the host. kill_by_pattern "node_modules/\.bin/vite" @@ -207,10 +468,16 @@ stop() { # 5. openwork-server / orchestrator / opencode / opencode-router for the # current dev build. These are the longest-lived children and the ones # most likely to orphan after an unclean shutdown. + # Tauri dev runs them from target/debug/, Electron dev runs them from + # src-tauri/sidecars/ — kill both trees, both are idempotent. kill_by_pattern "target/debug/openwork-server" kill_by_pattern "target/debug/openwork-orchestrator" kill_by_pattern "target/debug/opencode" kill_by_pattern "target/debug/opencode-router" + kill_by_pattern "apps/desktop/src-tauri/sidecars/openwork-server" + kill_by_pattern "apps/desktop/src-tauri/sidecars/openwork-orchestrator" + kill_by_pattern "apps/desktop/src-tauri/sidecars/opencode( |/)" + kill_by_pattern "apps/desktop/src-tauri/sidecars/opencode-router" # Safety net for stragglers we don't own directly. kill_orphans @@ -233,11 +500,22 @@ start() { fi fi - log "starting pnpm dev (log sink: $DEV_LOG_FILE)" cd "$REPO_ROOT" - env OPENWORK_DEV_LOG_FILE="$DEV_LOG_FILE" \ - nohup pnpm dev >"$PNPM_DEV_LOG" 2>&1 & - local pid=$! + local pid + case "$DEV_VARIANT" in + electron) + log "starting pnpm dev:electron (variant=electron, log sink: $DEV_LOG_FILE, CDP: 127.0.0.1:9823)" + env OPENWORK_DEV_LOG_FILE="$DEV_LOG_FILE" \ + nohup pnpm --filter @openwork/desktop dev:electron >"$PNPM_DEV_LOG" 2>&1 & + pid=$! + ;; + tauri) + log "starting pnpm dev (variant=tauri, log sink: $DEV_LOG_FILE)" + env OPENWORK_DEV_LOG_FILE="$DEV_LOG_FILE" \ + nohup pnpm dev >"$PNPM_DEV_LOG" 2>&1 & + pid=$! + ;; + esac disown "$pid" 2>/dev/null || true echo "$pid" >"$PNPM_DEV_PID_FILE" log "pnpm dev pid=$pid" @@ -282,8 +560,17 @@ reset() { echo snapshot | sed -n '1,20p' echo - log "reset complete — now reload the Tauri webview (Cmd+Shift+R) to drop" - log "its in-memory module cache and pick up the fresh Vite." + case "$DEV_VARIANT" in + electron) + log "reset complete (variant=electron) — Electron CDP should be up at" + log "http://127.0.0.1:9823; chrome-devtools MCP can attach there." + log "If the window looks stale, Cmd+Shift+R to drop its Vite module cache." + ;; + tauri) + log "reset complete — now reload the Tauri webview (Cmd+Shift+R) to drop" + log "its in-memory module cache and pick up the fresh Vite." + ;; + esac } reset_webview_state() { @@ -317,6 +604,9 @@ case "$cmd" in kill-orphans) kill_orphans ;; + diagnose-hang) + diagnose_hang + ;; stop) stop ;;