mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix: codex session selection
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user