From 91fe24167b7cac940df2ff748c4c3faa7ba90751 Mon Sep 17 00:00:00 2001 From: luoye520ww <100058663+luoye520ww@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:39:50 +0800 Subject: [PATCH] fix(runtime): preserve workbench across reconnects --- src/main/kun-runtime-supervisor.test.ts | 6 +- src/main/kun-runtime-supervisor.ts | 19 +++++- .../components/RuntimeStatusBanner.test.ts | 37 ++++++++++- .../src/components/RuntimeStatusBanner.tsx | 61 +++++++++++++++---- ...eWorkbenchComposerSubmitController.test.ts | 25 ++++++++ .../useWorkbenchComposerSubmitController.ts | 24 +++++--- .../src/lib/runtime-banner-visibility.test.ts | 5 ++ .../src/lib/runtime-banner-visibility.ts | 2 +- src/renderer/src/locales/en/common.json | 2 + src/renderer/src/locales/zh/common.json | 2 + .../store/chat-store-navigation-actions.ts | 35 +++++++++-- .../store/chat-store-thread-actions.test.ts | 29 +++++++++ .../src/store/chat-store-thread-actions.ts | 12 +++- src/shared/kun-gui-api.ts | 8 ++- 14 files changed, 234 insertions(+), 33 deletions(-) diff --git a/src/main/kun-runtime-supervisor.test.ts b/src/main/kun-runtime-supervisor.test.ts index 8957f7606..c6ff58f73 100644 --- a/src/main/kun-runtime-supervisor.test.ts +++ b/src/main/kun-runtime-supervisor.test.ts @@ -129,9 +129,9 @@ describe('KunRuntimeSupervisor', () => { it('restarts after the configured consecutive watchdog failures', async () => { const h = harness() await h.supervisor.watchdogTick() - expect(h.statuses).toEqual([]) + expect(h.statuses.map((status) => status.state)).toEqual(['degraded']) await h.supervisor.watchdogTick() - expect(h.statuses.map((status) => status.state)).toEqual(['restarting', 'running']) + expect(h.statuses.map((status) => status.state)).toEqual(['degraded', 'restarting', 'running']) }) it('does not recover or restart after shutdown begins', async () => { @@ -147,6 +147,8 @@ describe('KunRuntimeSupervisor', () => { await h.supervisor.watchdogTick() await h.supervisor.watchdogTick() expect(h.statuses.at(-1)).toMatchObject({ state: 'failed', source: 'watchdog' }) + await h.supervisor.watchdogTick() + expect(h.statuses.at(-1)).toMatchObject({ state: 'failed', source: 'watchdog' }) }) it('owns single-flight ensure operations for one runtime fingerprint', async () => { diff --git a/src/main/kun-runtime-supervisor.ts b/src/main/kun-runtime-supervisor.ts index 239d8f8ec..9d65b6aa6 100644 --- a/src/main/kun-runtime-supervisor.ts +++ b/src/main/kun-runtime-supervisor.ts @@ -200,6 +200,10 @@ export class KunRuntimeSupervisor { async watchdogTick(): Promise { if (this.watchdogTickInFlight || this.deps.isStopped()) return if (this.crashRecoveryInFlight || this.operations.hasPendingOperation()) return + // A failed/stopped status is a deliberate terminal state until the user + // asks for a reconnect. Do not let later watchdog ticks downgrade it back + // to degraded and silently re-enter automatic recovery. + if (this.currentStatus?.state === 'failed' || this.currentStatus?.state === 'stopped') return if (!this.deps.isChildRunning()) return this.watchdogTickInFlight = true try { @@ -213,7 +217,20 @@ export class KunRuntimeSupervisor { 'kun-watchdog', `health probe failed (${this.watchdogFailures}/${this.watchdogFailureThreshold})` ) - if (this.watchdogFailures < this.watchdogFailureThreshold) return + if (this.watchdogFailures < this.watchdogFailureThreshold) { + // Keep the renderer informed while the runtime is still alive but no + // longer healthy. This is deliberately separate from crash recovery: + // a transient probe failure must not be treated as a process crash or + // consume the restart budget. + if (this.currentStatus?.state !== 'degraded') { + this.publish({ + state: 'degraded', + source: 'watchdog', + message: 'Kun is responding slowly; waiting for the runtime to recover.' + }) + } + return + } this.watchdogFailures = 0 this.publish({ state: 'restarting', diff --git a/src/renderer/src/components/RuntimeStatusBanner.test.ts b/src/renderer/src/components/RuntimeStatusBanner.test.ts index 27da9df1a..44d1016eb 100644 --- a/src/renderer/src/components/RuntimeStatusBanner.test.ts +++ b/src/renderer/src/components/RuntimeStatusBanner.test.ts @@ -6,17 +6,22 @@ import type { KunRuntimeStatusPayload } from '@shared/kun-gui-api' import { RuntimeStatusBanner } from './RuntimeStatusBanner' const storeState = vi.hoisted(() => ({ - runtimeStatus: null as KunRuntimeStatusPayload | null + runtimeStatus: null as KunRuntimeStatusPayload | null, + probeRuntime: vi.fn(async () => undefined) })) vi.mock('../store/chat-store', () => ({ - useChatStore: (selector: (state: { runtimeStatus: KunRuntimeStatusPayload | null }) => unknown) => + useChatStore: (selector: (state: { + runtimeStatus: KunRuntimeStatusPayload | null + probeRuntime: typeof storeState.probeRuntime + }) => unknown) => selector(storeState) })) describe('RuntimeStatusBanner', () => { afterEach(() => { storeState.runtimeStatus = null + storeState.probeRuntime.mockClear() }) it('renders automatic restarts as an informational status banner', () => { @@ -50,4 +55,32 @@ describe('RuntimeStatusBanner', () => { expect(html).toContain('role="alert"') expect(html).toContain('border-amber-200') }) + + it('keeps the workbench mounted while the runtime is degraded', () => { + storeState.runtimeStatus = { + state: 'degraded', + source: 'watchdog', + at: '2026-06-18T15:02:00.000Z' + } + + const html = renderToStaticMarkup(createElement(RuntimeStatusBanner)) + + expect(html).toContain('data-variant="warning"') + expect(html).toContain('runtimeStatusDegraded') + expect(html).not.toContain('retryConnection') + }) + + it('offers an explicit reconnect action for an offline runtime', async () => { + storeState.runtimeStatus = { + state: 'offline', + source: 'health-check', + at: '2026-06-18T15:03:00.000Z' + } + + const html = renderToStaticMarkup(createElement(RuntimeStatusBanner)) + + expect(html).toContain('runtimeStatusOffline') + expect(html).toContain('aria-label="retryConnection"') + expect(html).toContain('role="alert"') + }) }) diff --git a/src/renderer/src/components/RuntimeStatusBanner.tsx b/src/renderer/src/components/RuntimeStatusBanner.tsx index bb6341e46..446c9b43c 100644 --- a/src/renderer/src/components/RuntimeStatusBanner.tsx +++ b/src/renderer/src/components/RuntimeStatusBanner.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { AlertTriangle, Loader2, X } from 'lucide-react' +import { AlertTriangle, Loader2, RefreshCw, WifiOff, X } from 'lucide-react' import { useChatStore } from '../store/chat-store' /** @@ -13,44 +13,71 @@ import { useChatStore } from '../store/chat-store' export function RuntimeStatusBanner(): ReactElement | null { const { t } = useTranslation('common') const status = useChatStore((s) => s.runtimeStatus) + const probeRuntime = useChatStore((s) => s.probeRuntime) const [dismissedAt, setDismissedAt] = useState(null) + const [retrying, setRetrying] = useState(false) if (!status) return null const recoveredWithRollback = status.state === 'running' && status.rolledBack === true const transient = status.state === 'restarting' || status.state === 'crashed' - if (!transient && !recoveredWithRollback) return null + const degraded = status.state === 'degraded' + const disconnected = status.state === 'offline' || status.state === 'failed' || status.state === 'stopped' + if (!transient && !degraded && !disconnected && !recoveredWithRollback) return null if (dismissedAt === status.at) return null const label = recoveredWithRollback ? t('runtimeStatusRolledBack') - : status.state === 'restarting' + : status.state === 'degraded' + ? t('runtimeStatusDegraded') + : status.state === 'restarting' ? typeof status.attempt === 'number' ? t('runtimeStatusRestartingAttempt', { attempt: status.attempt, max: status.maxAttempts ?? 3 }) : t('runtimeStatusRestarting') - : t('runtimeStatusCrashed') - const tone = recoveredWithRollback ? 'warning' : 'info' + : status.state === 'crashed' + ? t('runtimeStatusCrashed') + : status.state === 'offline' + ? t('runtimeStatusOffline') + : t('runtimeStatusFailed') + const tone = recoveredWithRollback || disconnected || status.state === 'degraded' ? 'warning' : 'info' const bannerClass = recoveredWithRollback ? 'border-amber-200/70 bg-[rgba(255,248,235,0.82)] dark:border-amber-800/50 dark:bg-amber-950/35' - : 'border-sky-200/70 bg-[rgba(239,248,255,0.82)] dark:border-sky-900/60 dark:bg-sky-950/30' + : disconnected || status.state === 'degraded' + ? 'border-amber-200/70 bg-[rgba(255,248,235,0.82)] dark:border-amber-800/50 dark:bg-amber-950/35' + : 'border-sky-200/70 bg-[rgba(239,248,255,0.82)] dark:border-sky-900/60 dark:bg-sky-950/30' const iconClass = recoveredWithRollback ? 'text-amber-700 dark:text-amber-300' - : 'text-sky-700 dark:text-sky-300' + : disconnected || status.state === 'degraded' + ? 'text-amber-700 dark:text-amber-300' + : 'text-sky-700 dark:text-sky-300' const textClass = recoveredWithRollback ? 'text-amber-950 dark:text-amber-100' - : 'text-sky-950 dark:text-sky-100' + : disconnected || status.state === 'degraded' + ? 'text-amber-950 dark:text-amber-100' + : 'text-sky-950 dark:text-sky-100' + const retry = async (): Promise => { + if (retrying) return + setRetrying(true) + try { + await probeRuntime('user', { restart: true }) + } finally { + setRetrying(false) + } + } return (
- {recoveredWithRollback ? ( + {recoveredWithRollback || degraded ? ( + ) : disconnected ? ( + ) : ( )} @@ -60,6 +87,18 @@ export function RuntimeStatusBanner(): ReactElement | null { > {label}

+ {disconnected ? ( + + ) : null} {recoveredWithRollback ? (