diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 7c7f6d3b..33aa0312 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -21,6 +21,7 @@ describe('ConversationService', () => { let originalProviderManagedByHost: string | undefined let originalDiagnosticsFile: string | undefined let originalAttributionHeader: string | undefined + let originalResumeInterruptedTurn: string | undefined let originalHome: string | undefined let originalPath: string | undefined let originalShell: string | undefined @@ -39,6 +40,7 @@ describe('ConversationService', () => { originalProviderManagedByHost = process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE originalAttributionHeader = process.env.CLAUDE_CODE_ATTRIBUTION_HEADER + originalResumeInterruptedTurn = process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN originalHome = process.env.HOME originalPath = process.env.PATH originalShell = process.env.SHELL @@ -57,6 +59,7 @@ describe('ConversationService', () => { delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER + delete process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1' resetTerminalShellEnvironmentCacheForTests() }) @@ -92,6 +95,9 @@ describe('ConversationService', () => { if (originalAttributionHeader === undefined) delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER else process.env.CLAUDE_CODE_ATTRIBUTION_HEADER = originalAttributionHeader + if (originalResumeInterruptedTurn === undefined) delete process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN + else process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN = originalResumeInterruptedTurn + if (originalHome === undefined) delete process.env.HOME else process.env.HOME = originalHome @@ -597,6 +603,18 @@ describe('ConversationService', () => { expect(env.CC_HAHA_DESKTOP_AWAIT_MCP_TIMEOUT_MS).toBe('5000') }) + test('buildChildEnv disables inherited interrupted-turn resume for prewarm launches', async () => { + process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN = '1' + const service = new ConversationService() as any + const env = (await service.buildChildEnv( + '/tmp', + 'ws://127.0.0.1:3456/sdk/test-session?token=test-token', + { resumeInterruptedTurn: false }, + )) as Record + + expect(env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN).toBeUndefined() + }) + test('buildChildEnv enables stream idle watchdog for desktop CLI sessions', async () => { const service = new ConversationService() as any const env = (await service.buildChildEnv( diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index e12e846c..570f3808 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -1875,7 +1875,7 @@ describe('WebSocket Chat Integration', () => { const { sessionId } = await createRes.json() as { sessionId: string } const originalStartSession = conversationService.startSession.bind(conversationService) - const startCalls: Array<{ sessionId: string }> = [] + const startCalls: Array<{ sessionId: string; options?: Record }> = [] conversationService.startSession = (async function patchedStartSession( sid: string, @@ -1883,7 +1883,7 @@ describe('WebSocket Chat Integration', () => { sdkUrl: string, options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null }, ) { - startCalls.push({ sessionId: sid }) + startCalls.push({ sessionId: sid, options: options as Record | undefined }) return originalStartSession(sid, workDir, sdkUrl, options) }) as typeof conversationService.startSession @@ -1972,6 +1972,8 @@ describe('WebSocket Chat Integration', () => { await completion expect(startCalls).toHaveLength(1) + expect(startCalls[0]!.sessionId).toBe(sessionId) + expect(startCalls[0]!.options?.resumeInterruptedTurn).toBe(false) expect(messages.some((msg) => msg.type === 'content_delta')).toBe(true) expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true) expect(messages.some((msg) => msg.type === 'error')).toBe(false) diff --git a/src/server/__tests__/server-shutdown.test.ts b/src/server/__tests__/server-shutdown.test.ts new file mode 100644 index 00000000..c4a737fa --- /dev/null +++ b/src/server/__tests__/server-shutdown.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from 'bun:test' +import { stopServerRuntimeForShutdown } from '../index.js' +import { conversationService } from '../services/conversationService.js' +import { cronScheduler } from '../services/cronScheduler.js' +import { teamWatcher } from '../services/teamWatcher.js' + +test('server shutdown stops background schedulers before waiting for CLI sessions', async () => { + const calls: string[] = [] + const originalTeamStop = teamWatcher.stop.bind(teamWatcher) + const originalCronStop = cronScheduler.stop.bind(cronScheduler) + const originalGetActiveSessions = conversationService.getActiveSessions.bind(conversationService) + const originalStopAllSessionsAndWait = conversationService.stopAllSessionsAndWait.bind(conversationService) + + try { + teamWatcher.stop = (() => { + calls.push('teamWatcher.stop') + }) as typeof teamWatcher.stop + cronScheduler.stop = (() => { + calls.push('cronScheduler.stop') + }) as typeof cronScheduler.stop + conversationService.getActiveSessions = (() => ['active-session']) as typeof conversationService.getActiveSessions + conversationService.stopAllSessionsAndWait = (async () => { + calls.push('conversationService.stopAllSessionsAndWait') + }) as typeof conversationService.stopAllSessionsAndWait + + await stopServerRuntimeForShutdown({ waitForCli: true }) + + expect(calls).toEqual([ + 'teamWatcher.stop', + 'cronScheduler.stop', + 'conversationService.stopAllSessionsAndWait', + ]) + } finally { + teamWatcher.stop = originalTeamStop + cronScheduler.stop = originalCronStop + conversationService.getActiveSessions = originalGetActiveSessions + conversationService.stopAllSessionsAndWait = originalStopAllSessionsAndWait + } +}) diff --git a/src/server/index.ts b/src/server/index.ts index d13f8493..4b9a26f5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -463,20 +463,29 @@ export function startServer(port = PORT, host = HOST) { let shutdownInProgress: Promise | null = null -function cleanupAllSessions() { +export async function stopServerRuntimeForShutdown( + options: { waitForCli?: boolean } = {}, +): Promise { + teamWatcher.stop() + cronScheduler.stop() + const active = conversationService.getActiveSessions() if (active.length > 0) { console.log(`[Server] Shutting down — killing ${active.length} CLI subprocess(es)`) - conversationService.stopAllSessions() + if (options.waitForCli === false) { + conversationService.stopAllSessions() + } else { + await conversationService.stopAllSessionsAndWait() + } } } +function cleanupAllSessions() { + void stopServerRuntimeForShutdown({ waitForCli: false }) +} + async function cleanupAllSessionsAndWait() { - const active = conversationService.getActiveSessions() - if (active.length > 0) { - console.log(`[Server] Shutting down — killing ${active.length} CLI subprocess(es)`) - await conversationService.stopAllSessionsAndWait() - } + await stopServerRuntimeForShutdown({ waitForCli: true }) } function shutdownAndExit(signal: 'SIGTERM' | 'SIGINT', exitCode: number) { diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 89969c62..56cb8a3d 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -121,6 +121,7 @@ type SessionStartOptions = { effort?: string thinking?: 'enabled' | 'adaptive' | 'disabled' providerId?: string | null + resumeInterruptedTurn?: boolean } export class ConversationStartupError extends Error { @@ -1042,6 +1043,9 @@ export class ConversationService { const cleanEnv = await getProcessEnvWithTerminalShellEnvironment() delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN + if (options?.resumeInterruptedTurn === false) { + delete cleanEnv.CLAUDE_CODE_RESUME_INTERRUPTED_TURN + } if (this.shouldStripInheritedProviderEnv(options?.providerId)) { for (const key of PROVIDER_ENV_KEYS) { delete cleanEnv[key] diff --git a/src/server/services/cronScheduler.ts b/src/server/services/cronScheduler.ts index 27e8259b..36724869 100644 --- a/src/server/services/cronScheduler.ts +++ b/src/server/services/cronScheduler.ts @@ -368,6 +368,9 @@ export class CronScheduler { /** Stop the scheduler and kill any running task processes. */ stop(): void { + const wasRunning = this.intervalId !== null || this.runningTasks.size > 0 + if (!wasRunning) return + if (this.intervalId) { clearInterval(this.intervalId) this.intervalId = null diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 27c64357..a0673b27 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -1310,12 +1310,15 @@ async function ensureCliSessionStarted( const workDir = await resolveSessionWorkDir(sessionId) lastResolvedStartupWorkDirs.set(sessionId, workDir) const runtimeSettings = await getRuntimeSettings(sessionId) + const startupSettings = reason === 'prewarm_session' + ? { ...runtimeSettings, resumeInterruptedTurn: false } + : runtimeSettings const sdkUrl = `ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` + `?token=${encodeURIComponent(crypto.randomUUID())}` await sendRepositoryStartupStatus(ws, sessionId, reason) console.log(`[WS] Starting CLI for ${sessionId} due to ${reason}`) - await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings) + await conversationService.startSession(sessionId, workDir, sdkUrl, startupSettings) })() sessionStartupPromises.set(sessionId, startup)