diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index a2b4986a..29c57603 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -7,10 +7,26 @@ import { convertCodexEvent } from './utils/codexEventConverter'; import { getLocalLaunchExitReason } from '@/agent/localLaunchPolicy'; export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> { + let exitReason: 'switch' | 'exit' | null = null; + const processAbortController = new AbortController(); + const exitFuture = new Future(); + + const handleSessionMatchFailed = (message: string) => { + logger.warn(`[codex-local]: ${message}`); + session.sendSessionEvent({ type: 'message', message }); + if (!exitReason) { + exitReason = 'exit'; + } + if (!processAbortController.signal.aborted) { + processAbortController.abort(); + } + }; + const scanner = await createCodexSessionScanner({ sessionId: session.sessionId, cwd: session.path, startupTimestampMs: Date.now(), + onSessionMatchFailed: handleSessionMatchFailed, onSessionFound: (sessionId) => { session.onSessionFound(sessionId); }, @@ -29,10 +45,6 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch } }); - let exitReason: 'switch' | 'exit' | null = null; - const processAbortController = new AbortController(); - const exitFuture = new Future(); - try { async function abortProcess() { if (!processAbortController.signal.aborted) { @@ -64,6 +76,9 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch void doSwitch(); }); + if (exitReason) { + return exitReason; + } if (session.queue.size() > 0) { return 'switch'; } diff --git a/cli/src/codex/utils/codexSessionScanner.test.ts b/cli/src/codex/utils/codexSessionScanner.test.ts index 8ea8e78c..739bc658 100644 --- a/cli/src/codex/utils/codexSessionScanner.test.ts +++ b/cli/src/codex/utils/codexSessionScanner.test.ts @@ -73,4 +73,79 @@ describe('codexSessionScanner', () => { expect(events).toHaveLength(1); expect(events[0].type).toBe('response_item'); }); + + it('limits session scan to dates within the start window', async () => { + const referenceTimestampMs = Date.parse('2025-12-22T00:00:30.000Z'); + const windowMs = 2 * 60 * 1000; + const matchingSessionId = 'session-222'; + const outsideSessionId = 'session-999'; + const outsideDir = join(testDir, 'sessions', '2025', '12', '20'); + const matchingFile = join(sessionsDir, `codex-${matchingSessionId}.jsonl`); + const outsideFile = join(outsideDir, `codex-${outsideSessionId}.jsonl`); + + await mkdir(outsideDir, { recursive: true }); + const baseLines = [ + JSON.stringify({ type: 'session_meta', payload: { id: matchingSessionId, cwd: '/data/github/happy/hapi', timestamp: '2025-12-22T00:00:00.000Z' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'hello' } }) + ]; + await writeFile(matchingFile, baseLines.join('\n') + '\n'); + await writeFile( + outsideFile, + JSON.stringify({ type: 'session_meta', payload: { id: outsideSessionId, cwd: '/data/github/happy/hapi', timestamp: '2025-12-20T00:00:00.000Z' } }) + '\n' + ); + + scanner = await createCodexSessionScanner({ + sessionId: null, + cwd: '/data/github/happy/hapi', + startupTimestampMs: referenceTimestampMs, + sessionStartWindowMs: windowMs, + onEvent: (event) => events.push(event) + }); + + await wait(200); + expect(events).toHaveLength(0); + + const newLine = JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'Tool', call_id: 'call-2', arguments: '{}' } + }); + await appendFile(matchingFile, newLine + '\n'); + + await wait(200); + expect(events).toHaveLength(1); + expect(events[0].type).toBe('response_item'); + }); + + it('fails fast when cwd is missing and no sessionId is provided', async () => { + const sessionId = 'session-missing-cwd'; + const matchFailedMessage = 'No cwd provided for Codex session matching; refusing to fallback.'; + sessionFile = join(sessionsDir, `codex-${sessionId}.jsonl`); + + await writeFile( + sessionFile, + JSON.stringify({ type: 'session_meta', payload: { id: sessionId } }) + '\n' + ); + + let failureMessage: string | null = null; + scanner = await createCodexSessionScanner({ + sessionId: null, + onEvent: (event) => events.push(event), + onSessionMatchFailed: (message) => { + failureMessage = message; + } + }); + + await wait(150); + expect(failureMessage).toBe(matchFailedMessage); + expect(events).toHaveLength(0); + + const newLine = JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'Tool', call_id: 'call-3', arguments: '{}' } + }); + await appendFile(sessionFile, newLine + '\n'); + + await wait(200); + expect(events).toHaveLength(0); + }); }); diff --git a/cli/src/codex/utils/codexSessionScanner.ts b/cli/src/codex/utils/codexSessionScanner.ts index 041faaef..5dc4c8e6 100644 --- a/cli/src/codex/utils/codexSessionScanner.ts +++ b/cli/src/codex/utils/codexSessionScanner.ts @@ -1,7 +1,7 @@ import { InvalidateSync } from '@/utils/sync'; import { startFileWatcher } from '@/modules/watcher/startFileWatcher'; import { logger } from '@/ui/logger'; -import { join, resolve } from 'node:path'; +import { join, relative, resolve, sep } from 'node:path'; import { homedir } from 'node:os'; import { readFile, readdir, stat } from 'node:fs/promises'; import type { CodexSessionEvent } from './codexEventConverter'; @@ -10,6 +10,7 @@ interface CodexSessionScannerOptions { sessionId: string | null; onEvent: (event: CodexSessionEvent) => void; onSessionFound?: (sessionId: string) => void; + onSessionMatchFailed?: (message: string) => void; cwd?: string; startupTimestampMs?: number; sessionStartWindowMs?: number; @@ -25,13 +26,9 @@ type PendingEvents = { fileSessionId: string | null; }; -type CandidateReason = 'within-window' | 'outside-window' | 'no-timestamp' | 'unknown-cwd'; - type Candidate = { sessionId: string; - filePath: string; score: number; - reason: CandidateReason; }; const DEFAULT_SESSION_START_WINDOW_MS = 2 * 60 * 1000; @@ -51,12 +48,28 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions let activeSessionId: string | null = opts.sessionId; let reportedSessionId: string | null = opts.sessionId; let isClosing = false; + let matchFailed = false; - const targetCwd = opts.cwd ? normalizePath(opts.cwd) : null; + const targetCwd = opts.cwd && opts.cwd.trim().length > 0 ? normalizePath(opts.cwd) : null; const referenceTimestampMs = opts.startupTimestampMs ?? Date.now(); const sessionStartWindowMs = opts.sessionStartWindowMs ?? DEFAULT_SESSION_START_WINDOW_MS; + const matchDeadlineMs = referenceTimestampMs + sessionStartWindowMs; + const sessionDatePrefixes = targetCwd + ? getSessionDatePrefixes(referenceTimestampMs, sessionStartWindowMs) + : null; logger.debug(`[CODEX_SESSION_SCANNER] Init: targetCwd=${targetCwd ?? 'none'} startupTs=${new Date(referenceTimestampMs).toISOString()} windowMs=${sessionStartWindowMs}`); + if (!targetCwd && !opts.sessionId) { + matchFailed = true; + const message = 'No cwd provided for Codex session matching; refusing to fallback.'; + logger.warn(`[CODEX_SESSION_SCANNER] ${message}`); + opts.onSessionMatchFailed?.(message); + return { + cleanup: async () => {}, + onNewSession: () => {} + }; + } + function reportSessionId(sessionId: string): void { if (reportedSessionId === sessionId) { return; @@ -81,6 +94,9 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions const results: string[] = []; for (const entry of entries) { const full = join(dir, entry.name); + if (!shouldIncludeSessionPath(full, sessionsRoot, sessionDatePrefixes)) { + continue; + } if (entry.isDirectory()) { results.push(...await listSessionFiles(full)); } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { @@ -168,44 +184,23 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions } const fileCwd = sessionCwdByFile.get(filePath); - if (targetCwd && fileCwd && fileCwd !== targetCwd) { + if (targetCwd && fileCwd !== targetCwd) { return null; } - if (targetCwd && !fileCwd) { - return { - sessionId, - filePath, - score: Number.POSITIVE_INFINITY, - reason: 'unknown-cwd' - }; - } - const sessionTimestamp = sessionTimestampByFile.get(filePath); if (sessionTimestamp === undefined) { - return { - sessionId, - filePath, - score: Number.POSITIVE_INFINITY, - reason: 'no-timestamp' - }; + return null; } const diff = Math.abs(sessionTimestamp - referenceTimestampMs); if (diff > sessionStartWindowMs) { - return { - sessionId, - filePath, - score: diff, - reason: 'outside-window' - }; + return null; } return { sessionId, - filePath, - score: diff, - reason: 'within-window' + score: diff }; } @@ -234,10 +229,6 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions const payloadSessionId = payload ? asString(payload.id) : null; const eventSessionId = payloadSessionId ?? fileSessionId ?? null; - if (!activeSessionId && !targetCwd && eventSessionId) { - setActiveSessionId(eventSessionId); - } - if (activeSessionId && eventSessionId && eventSessionId !== activeSessionId) { continue; } @@ -268,15 +259,12 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions } const sync = new InvalidateSync(async () => { - if (isClosing) { + if (isClosing || matchFailed) { return; } const files = await listSessionFiles(sessionsRoot); const sortedFiles = await sortFilesByMtime(files); let bestWithinWindow: Candidate | null = null; - let bestOutsideWindow: Candidate | null = null; - let bestNoTimestamp: Candidate | null = null; - let bestUnknownCwd: Candidate | null = null; for (const filePath of sortedFiles) { if (isClosing) { @@ -299,29 +287,10 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions processedLineCounts.set(filePath, totalLines); const candidate = !activeSessionId && targetCwd ? getCandidateForFile(filePath) : null; if (!activeSessionId && targetCwd) { - appendPendingEvents(filePath, events, fileSessionId ?? candidate?.sessionId ?? null); + appendPendingEvents(filePath, events, fileSessionId ?? null); if (candidate) { - switch (candidate.reason) { - case 'within-window': - if (!bestWithinWindow || candidate.score < bestWithinWindow.score) { - bestWithinWindow = candidate; - } - break; - case 'outside-window': - if (!bestOutsideWindow || candidate.score < bestOutsideWindow.score) { - bestOutsideWindow = candidate; - } - break; - case 'no-timestamp': - if (!bestNoTimestamp) { - bestNoTimestamp = candidate; - } - break; - case 'unknown-cwd': - if (!bestUnknownCwd) { - bestUnknownCwd = candidate; - } - break; + if (!bestWithinWindow || candidate.score < bestWithinWindow.score) { + bestWithinWindow = candidate; } } continue; @@ -334,18 +303,15 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions } if (!activeSessionId && targetCwd) { - const selectedCandidate = bestWithinWindow - ?? bestOutsideWindow - ?? bestNoTimestamp - ?? bestUnknownCwd - ?? null; - if (selectedCandidate) { - if (selectedCandidate.reason === 'within-window') { - logger.debug(`[CODEX_SESSION_SCANNER] Selected session ${selectedCandidate.sessionId} within start window`); - } else { - logger.debug(`[CODEX_SESSION_SCANNER] Selected session ${selectedCandidate.sessionId} via fallback (${selectedCandidate.reason})`); - } - setActiveSessionId(selectedCandidate.sessionId); + if (bestWithinWindow) { + logger.debug(`[CODEX_SESSION_SCANNER] Selected session ${bestWithinWindow.sessionId} within start window`); + setActiveSessionId(bestWithinWindow.sessionId); + } else if (Date.now() > matchDeadlineMs) { + matchFailed = true; + pendingEventsByFile.clear(); + const message = `No Codex session found within ${sessionStartWindowMs}ms for cwd ${targetCwd}; refusing fallback.`; + logger.warn(`[CODEX_SESSION_SCANNER] ${message}`); + opts.onSessionMatchFailed?.(message); } else if (pendingEventsByFile.size > 0) { logger.debug('[CODEX_SESSION_SCANNER] No session candidate matched yet; pending events buffered'); } @@ -418,3 +384,55 @@ function normalizePath(value: string): string { const resolved = resolve(value); return process.platform === 'win32' ? resolved.toLowerCase() : resolved; } + +function getSessionDatePrefixes(referenceTimestampMs: number, windowMs: number): Set { + const startDate = new Date(referenceTimestampMs - windowMs); + const endDate = new Date(referenceTimestampMs + windowMs); + const current = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate()); + const last = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()); + const prefixes = new Set(); + + while (current <= last) { + const year = String(current.getFullYear()); + const month = String(current.getMonth() + 1).padStart(2, '0'); + const day = String(current.getDate()).padStart(2, '0'); + prefixes.add(`${year}/${month}/${day}`); + current.setDate(current.getDate() + 1); + } + + return prefixes; +} + +function shouldIncludeSessionPath( + fullPath: string, + sessionsRoot: string, + prefixes: Set | null +): boolean { + if (!prefixes) { + return true; + } + + const relativePath = relative(sessionsRoot, fullPath); + if (!relativePath || relativePath.startsWith('..')) { + return true; + } + + const normalized = relativePath.split(sep).filter(Boolean).join('/'); + if (!normalized) { + return true; + } + + for (const prefix of prefixes) { + if (normalized === prefix) { + return true; + } + if (normalized.startsWith(`${prefix}/`)) { + return true; + } + if (prefix.startsWith(`${normalized}/`)) { + return true; + } + } + + return false; +}