fix: codex session selection

This commit is contained in:
weishu
2025-12-27 22:24:18 +08:00
parent 7c1baa28ee
commit 007721bdc8
3 changed files with 185 additions and 77 deletions
+19 -4
View File
@@ -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<void>();
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<void>();
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';
}
@@ -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);
});
});
+91 -73
View File
@@ -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<string> {
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<string>();
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<string> | 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;
}