diff --git a/src/hooks/useClaudeState.ts b/src/hooks/useClaudeState.ts index c2c53b75..86f0a5f8 100644 --- a/src/hooks/useClaudeState.ts +++ b/src/hooks/useClaudeState.ts @@ -61,7 +61,13 @@ export function useClaudeState(sessionId: string) { ); const sendMutation = trpc.claude.send.useMutation(); - const interruptMutation = trpc.claude.interrupt.useMutation(); + const interruptMutation = trpc.claude.interrupt.useMutation({ + onError: () => { + // If interrupt fails (e.g. timeout), refetch running state + // so the UI doesn't stay stuck on "Interrupting..." + void refetch(); + }, + }); const answerMutation = trpc.claude.answerQuestion.useMutation(); const send = useCallback( diff --git a/src/hooks/useSessionState.ts b/src/hooks/useSessionState.ts index e315ca0c..3ba035d4 100644 --- a/src/hooks/useSessionState.ts +++ b/src/hooks/useSessionState.ts @@ -41,6 +41,11 @@ export function useSessionState(sessionId: string) { onSuccess: (data) => { utils.sessions.get.setData({ sessionId }, { session: data.session }); }, + onError: () => { + // If the stop mutation fails (e.g. timeout or server error), + // refetch session state so we don't stay stuck on "Stopping..." + void refetch(); + }, }); // The API endpoint is "delete" but it now archives instead of permanently deleting diff --git a/src/server/services/claude-runner.ts b/src/server/services/claude-runner.ts index b76e8382..099002f3 100644 --- a/src/server/services/claude-runner.ts +++ b/src/server/services/claude-runner.ts @@ -603,8 +603,21 @@ export function getPendingInput( }; } +/** + * Race a promise against a timeout. Resolves with `undefined` if the timeout fires first. + */ +function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(undefined), ms)), + ]); +} + +const INTERRUPT_TIMEOUT_MS = 5_000; + /** * Interrupt a running Claude query. + * Uses a timeout to prevent hanging if the SDK's interrupt() never resolves. */ export async function interruptClaude(sessionId: string): Promise { const state = sessions.get(sessionId); @@ -614,10 +627,18 @@ export async function interruptClaude(sessionId: string): Promise { } try { - await state.currentQuery.interrupt(); + const result = await withTimeout(state.currentQuery.interrupt(), INTERRUPT_TIMEOUT_MS); + if (result === undefined) { + log.warn('interruptClaude: Timed out, force-cleaning state', { sessionId }); + forceCleanSession(sessionId); + } return true; } catch (err) { - log.warn('interruptClaude: Failed', { sessionId, error: toError(err).message }); + log.warn('interruptClaude: Failed, force-cleaning state', { + sessionId, + error: toError(err).message, + }); + forceCleanSession(sessionId); return false; } } @@ -709,6 +730,37 @@ export async function markLastMessageAsInterrupted(sessionId: string): Promise