From a30bb398e1e5b24c2b8cb863ee4e1aaf0d7cc65d Mon Sep 17 00:00:00 2001 From: ROOOO Date: Wed, 18 Mar 2026 07:27:23 +0800 Subject: [PATCH] fix(codex): keep local session timeout non-fatal (#304) --- cli/src/codex/codexLocalLauncher.test.ts | 94 ++++++++++++++++++------ cli/src/codex/codexLocalLauncher.ts | 6 +- 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts index 66866d03..fd2e9516 100644 --- a/cli/src/codex/codexLocalLauncher.test.ts +++ b/cli/src/codex/codexLocalLauncher.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const harness = vi.hoisted(() => ({ - launches: [] as Array> + launches: [] as Array>, + sessionScannerCalls: [] as Array>, + scannerFailureMessage: 'No Codex session found within 120000ms for cwd c:\\workspace\\project; refusing fallback.' })); vi.mock('./codexLocal', () => ({ @@ -21,10 +23,18 @@ vi.mock('./utils/buildHapiMcpBridge', () => ({ })); vi.mock('./utils/codexSessionScanner', () => ({ - createCodexSessionScanner: async () => ({ - cleanup: async () => {}, - onNewSession: () => {} - }) + createCodexSessionScanner: async (opts: { + onSessionMatchFailed?: (message: string) => void; + }) => { + harness.sessionScannerCalls.push(opts as Record); + return { + cleanup: async () => {}, + onNewSession: () => {}, + triggerFailure: () => { + opts.onSessionMatchFailed?.(harness.scannerFailureMessage); + } + }; + } })); vi.mock('@/modules/common/launcher/BaseLocalLauncher', () => ({ @@ -44,33 +54,55 @@ vi.mock('@/modules/common/launcher/BaseLocalLauncher', () => ({ import { codexLocalLauncher } from './codexLocalLauncher'; -function createSessionStub(permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo', codexArgs?: string[]) { +function createQueueStub() { return { - sessionId: null, - path: '/tmp/worktree', - startedBy: 'terminal' as const, - startingMode: 'local' as const, - codexArgs, - client: { - rpcHandlerManager: {} + size: () => 0, + reset: () => {}, + setOnMessage: () => {} + }; +} + +function createSessionStub(permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo', codexArgs?: string[], path = '/tmp/worktree') { + const sessionEvents: Array<{ type: string; message?: string }> = []; + let localLaunchFailure: { message: string; exitReason: 'switch' | 'exit' } | null = null; + + return { + session: { + sessionId: null, + path, + startedBy: 'terminal' as const, + startingMode: 'local' as const, + codexArgs, + client: { + rpcHandlerManager: { + registerHandler: () => {} + } + }, + getPermissionMode: () => permissionMode, + onSessionFound: () => {}, + sendSessionEvent: (event: { type: string; message?: string }) => { + sessionEvents.push(event); + }, + recordLocalLaunchFailure: (message: string, exitReason: 'switch' | 'exit') => { + localLaunchFailure = { message, exitReason }; + }, + sendUserMessage: () => {}, + sendCodexMessage: () => {}, + queue: createQueueStub() }, - getPermissionMode: () => permissionMode, - onSessionFound: () => {}, - sendSessionEvent: () => {}, - recordLocalLaunchFailure: () => {}, - sendUserMessage: () => {}, - sendCodexMessage: () => {}, - queue: {} + sessionEvents, + getLocalLaunchFailure: () => localLaunchFailure }; } describe('codexLocalLauncher', () => { afterEach(() => { harness.launches = []; + harness.sessionScannerCalls = []; }); it('rebuilds approval and sandbox args from yolo mode', async () => { - const session = createSessionStub('yolo', [ + const { session } = createSessionStub('yolo', [ '--sandbox', 'read-only', '--ask-for-approval', @@ -94,7 +126,7 @@ describe('codexLocalLauncher', () => { }); it('preserves raw Codex approval flags in default mode', async () => { - const session = createSessionStub('default', [ + const { session } = createSessionStub('default', [ '--ask-for-approval', 'on-request', '--sandbox', @@ -117,7 +149,7 @@ describe('codexLocalLauncher', () => { }); it('keeps sandbox escalation available in safe-yolo mode', async () => { - const session = createSessionStub('safe-yolo', [ + const { session } = createSessionStub('safe-yolo', [ '--ask-for-approval', 'never', '--sandbox', @@ -138,4 +170,20 @@ describe('codexLocalLauncher', () => { 'o3' ]); }); + + it('warns on session match failure without aborting local Codex launch', async () => { + const { session, sessionEvents, getLocalLaunchFailure } = createSessionStub('default', undefined, 'c:\\workspace\\project'); + + await codexLocalLauncher(session as never); + + const scannerCall = harness.sessionScannerCalls[0] as { onSessionMatchFailed?: (message: string) => void } | undefined; + scannerCall?.onSessionMatchFailed?.(harness.scannerFailureMessage); + + expect(harness.launches).toHaveLength(1); + expect(getLocalLaunchFailure()).toBeNull(); + expect(sessionEvents).toContainEqual({ + type: 'message', + message: `${harness.scannerFailureMessage} Keeping local Codex running; remote transcript sync may be unavailable for this launch.` + }); + }); }); diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index d44b2d08..260741fd 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -60,8 +60,10 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch const handleSessionMatchFailed = (message: string) => { logger.warn(`[codex-local]: ${message}`); - session.sendSessionEvent({ type: 'message', message }); - launcher.control.requestExit(); + session.sendSessionEvent({ + type: 'message', + message: `${message} Keeping local Codex running; remote transcript sync may be unavailable for this launch.` + }); }; scanner = await createCodexSessionScanner({