Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/main/kun-runtime-supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
19 changes: 18 additions & 1 deletion src/main/kun-runtime-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ export class KunRuntimeSupervisor<Settings> {
async watchdogTick(): Promise<void> {
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 {
Expand All @@ -213,7 +217,20 @@ export class KunRuntimeSupervisor<Settings> {
'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',
Expand Down
37 changes: 35 additions & 2 deletions src/renderer/src/components/RuntimeStatusBanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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"')
})
})
61 changes: 50 additions & 11 deletions src/renderer/src/components/RuntimeStatusBanner.tsx
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand All @@ -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<string | null>(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<void> => {
if (retrying) return
setRetrying(true)
try {
await probeRuntime('user', { restart: true })
} finally {
setRetrying(false)
}
}
return (
<div
className={`ds-no-drag shrink-0 border-b backdrop-blur-lg ${bannerClass}`}
data-variant={tone}
role={recoveredWithRollback ? 'alert' : 'status'}
role={recoveredWithRollback || disconnected ? 'alert' : 'status'}
>
<div className="flex w-full min-w-0 items-center gap-2 px-4 py-1.5">
{recoveredWithRollback ? (
{recoveredWithRollback || degraded ? (
<AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${iconClass}`} strokeWidth={2} />
) : disconnected ? (
<WifiOff className={`h-3.5 w-3.5 shrink-0 ${iconClass}`} strokeWidth={2} />
) : (
<Loader2
className={`h-3.5 w-3.5 shrink-0 animate-spin ${iconClass}`}
className={`h-3.5 w-3.5 shrink-0 ${transient ? 'animate-spin' : ''} ${iconClass}`}
strokeWidth={2}
/>
)}
Expand All @@ -60,6 +87,18 @@ export function RuntimeStatusBanner(): ReactElement | null {
>
{label}
</p>
{disconnected ? (
<button
type="button"
aria-label={t('retryConnection')}
disabled={retrying}
className="inline-flex shrink-0 items-center gap-1 rounded-md border border-amber-300/70 bg-white/80 px-2.5 py-1 text-[12px] font-medium text-amber-900/85 transition hover:bg-amber-100/70 disabled:cursor-wait disabled:opacity-60 dark:border-amber-700/60 dark:bg-amber-900/20 dark:text-amber-100 dark:hover:bg-amber-900/40"
onClick={() => void retry()}
>
<RefreshCw className={`h-3.5 w-3.5 ${retrying ? 'animate-spin' : ''}`} strokeWidth={2} />
{t('retryConnection')}
</button>
) : null}
{recoveredWithRollback ? (
<button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,31 @@ describe('useWorkbenchComposerSubmitController', () => {
await vi.waitFor(() => expect(input.getValue()).toBe('keep this prompt'))
})

it('keeps a chat draft and attachments when the runtime rejects a send', async () => {
const input = inputHarness('keep this chat prompt')
const sendMessage = vi.fn(async () => false)
const removeComposerAttachments = vi.fn()
const controller = useWorkbenchComposerSubmitController(controllerParams({
input: 'keep this chat prompt',
route: 'chat',
composerAttachments: [{
id: 'attachment-1',
kind: 'image',
name: 'screen.png',
mimeType: 'image/png'
}],
removeComposerAttachments,
sendMessage,
setInput: input.setInput
}))

controller.handleSend()

await vi.waitFor(() => expect(sendMessage).toHaveBeenCalledOnce())
expect(input.getValue()).toBe('keep this chat prompt')
expect(removeComposerAttachments).not.toHaveBeenCalled()
})

it('saves the captured draft before sending it to the writing assistant', async () => {
const write = deferred<{ ok: true; path: string; savedAt: string }>()
const writeWorkspaceFile = vi.fn(() => write.promise)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ export function useWorkbenchComposerSubmitController({
activeThreadId,
attachmentUploadEnabled,
buildCodeCanvasOutboundPrompt,
clearComposerAttachments,
removeComposerAttachments,
clearComposerFileReferences,
composerAttachments,
Expand Down Expand Up @@ -541,16 +540,17 @@ export function useWorkbenchComposerSubmitController({
if (route === 'chat' && composerMode === 'plan') {
const prepared = await prepareChatMessage()
if (!prepared) return
setInput('')
clearComposerAttachments(attachmentScope)
clearComposerFileReferences()
void sendPlanTurn(prepared.text, {
const sent = await sendPlanTurn(prepared.text, {
...(prepared.displayText ? { displayText: prepared.displayText } : {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(attachmentIds.length ? { attachmentIds } : {}),
...(publicAttachments.length ? { attachments: publicAttachments } : {}),
...(userFileReferences.length ? { fileReferences: userFileReferences } : {})
})
if (!sent) return
setInput((current) => current.trim() === v ? '' : current)
if (attachmentIds.length > 0) removeComposerAttachments(attachmentIds, attachmentScope)
clearComposerFileReferences()
return
}
if (route === 'write') {
Expand Down Expand Up @@ -653,9 +653,6 @@ export function useWorkbenchComposerSubmitController({
}
const prepared = await prepareChatMessage()
if (!prepared) return
setInput('')
clearComposerAttachments(attachmentScope)
clearComposerFileReferences()
let outboundText = prepared.text
let outboundDisplay = prepared.displayText
let outboundGuiDesignCanvas = false
Expand All @@ -677,14 +674,21 @@ export function useWorkbenchComposerSubmitController({
outboundDisplay = codeCanvasRoute.displayText
outboundGuiDesignCanvas = true
}
void sendMessage(outboundText, composerMode === 'plan' ? 'plan' : 'agent', {
const sent = await sendMessage(outboundText, composerMode === 'plan' ? 'plan' : 'agent', {
...(outboundDisplay ? { displayText: outboundDisplay } : {}),
...(outboundGuiDesignCanvas ? { guiDesignCanvas: true } : {}),
...(reasoningEffort ? { reasoningEffort } : {}),
...(attachmentIds.length ? { attachmentIds } : {}),
...(publicAttachments.length ? { attachments: publicAttachments } : {}),
...(userFileReferences.length ? { fileReferences: userFileReferences } : {})
})
// Keep the draft and attachments available when a health transition
// races the send. Only consume the captured composer inputs after the
// runtime has accepted the turn; this makes reconnect a safe retry.
if (!sent) return
setInput((current) => current.trim() === v ? '' : current)
if (attachmentIds.length > 0) removeComposerAttachments(attachmentIds, attachmentScope)
clearComposerFileReferences()
})()
}, [
activeClawChannelId,
Expand All @@ -695,7 +699,6 @@ export function useWorkbenchComposerSubmitController({
attachmentUploadEnabled,
buildCodeCanvasOutboundPrompt,
clawHelpText,
clearComposerAttachments,
clearComposerFileReferences,
clawModelListText,
composerAttachments,
Expand All @@ -708,6 +711,7 @@ export function useWorkbenchComposerSubmitController({
input,
mirrorClawCommand,
readComposerFileContextEntries,
removeComposerAttachments,
resetClawChannelSession,
rightPanelMode,
route,
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/src/lib/runtime-banner-visibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { shouldSuppressRuntimeErrorBanner } from './runtime-banner-visibility'

describe('shouldSuppressRuntimeErrorBanner', () => {
it('suppresses the main error banner while Kun is auto-restarting or recovering', () => {
expect(shouldSuppressRuntimeErrorBanner({
state: 'degraded',
source: 'watchdog',
at: '2026-06-18T14:59:59.000Z'
})).toBe(true)
expect(shouldSuppressRuntimeErrorBanner({
state: 'restarting',
source: 'settings-apply',
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/lib/runtime-banner-visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ import type { KunRuntimeStatusPayload } from '@shared/kun-gui-api'
export function shouldSuppressRuntimeErrorBanner(
status: KunRuntimeStatusPayload | null | undefined
): boolean {
return status?.state === 'restarting' || status?.state === 'crashed'
return status?.state === 'degraded' || status?.state === 'restarting' || status?.state === 'crashed'
}
2 changes: 2 additions & 0 deletions src/renderer/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"runtimeStatusRestarting": "Kun is restarting automatically…",
"runtimeStatusRestartingAttempt": "Kun is restarting automatically (attempt {{attempt}}/{{max}})…",
"runtimeStatusCrashed": "Kun exited unexpectedly; recovering…",
"runtimeStatusDegraded": "Kun is temporarily unhealthy; your conversation stays here while it recovers.",
"runtimeStatusOffline": "Kun is offline. Your conversation and draft are preserved; reconnect to continue.",
"runtimeStatusRolledBack": "The new settings failed to apply; rolled back to the previous working configuration",
"runtimeStatusFailed": "Kun keeps failing to start; automatic restarts are paused",
"runtimeStatusDismiss": "Dismiss",
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"runtimeStatusRestarting": "Kun 正在自动重启…",
"runtimeStatusRestartingAttempt": "Kun 正在自动重启(第 {{attempt}}/{{max}} 次)…",
"runtimeStatusCrashed": "Kun 意外退出,正在自动恢复…",
"runtimeStatusDegraded": "Kun 暂时不健康,正在恢复中; 当前会话会保留。",
"runtimeStatusOffline": "Kun 当前离线。会话和草稿已保留,重新连接后即可继续。",
"runtimeStatusRolledBack": "新设置应用失败,已自动回滚到之前可用的配置",
"runtimeStatusFailed": "Kun 多次启动失败,已暂停自动重启",
"runtimeStatusDismiss": "关闭",
Expand Down
Loading
Loading