mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(codex): improve reused session adoption (#305)
This commit is contained in:
@@ -148,4 +148,132 @@ describe('codexSessionScanner', () => {
|
||||
await wait(200);
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('adopts a reused older session file when fresh matching activity appears after startup', async () => {
|
||||
const reusedSessionId = 'session-reused-old-file';
|
||||
const targetCwd = '/data/github/happy/hapi';
|
||||
const startupTimestampMs = Date.now();
|
||||
const now = new Date(startupTimestampMs);
|
||||
const currentSessionsDir = join(
|
||||
testDir,
|
||||
'sessions',
|
||||
String(now.getFullYear()),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0')
|
||||
);
|
||||
await mkdir(currentSessionsDir, { recursive: true });
|
||||
sessionFile = join(currentSessionsDir, `codex-${reusedSessionId}.jsonl`);
|
||||
|
||||
await writeFile(
|
||||
sessionFile,
|
||||
JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: reusedSessionId,
|
||||
cwd: targetCwd,
|
||||
timestamp: new Date(startupTimestampMs - 10 * 60 * 1000).toISOString()
|
||||
}
|
||||
}) + '\n'
|
||||
);
|
||||
|
||||
let matchedSessionId: string | null = null;
|
||||
scanner = await createCodexSessionScanner({
|
||||
sessionId: null,
|
||||
cwd: targetCwd,
|
||||
startupTimestampMs,
|
||||
onEvent: (event) => events.push(event),
|
||||
onSessionFound: (sessionId) => {
|
||||
matchedSessionId = sessionId;
|
||||
}
|
||||
});
|
||||
|
||||
await wait(150);
|
||||
expect(events).toHaveLength(0);
|
||||
expect(matchedSessionId).toBeNull();
|
||||
|
||||
const newLine = JSON.stringify({
|
||||
type: 'response_item',
|
||||
payload: { type: 'function_call', name: 'Tool', call_id: 'call-reused', arguments: '{}' }
|
||||
});
|
||||
await appendFile(sessionFile, newLine + '\n');
|
||||
|
||||
await wait(2300);
|
||||
expect(matchedSessionId).toBe(reusedSessionId);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].type).toBe('response_item');
|
||||
});
|
||||
|
||||
it('does not adopt a reused session when first fresh matching activity is ambiguous', async () => {
|
||||
const targetCwd = '/data/github/happy/hapi';
|
||||
const startupTimestampMs = Date.now();
|
||||
const now = new Date(startupTimestampMs);
|
||||
const currentSessionsDir = join(
|
||||
testDir,
|
||||
'sessions',
|
||||
String(now.getFullYear()),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0')
|
||||
);
|
||||
await mkdir(currentSessionsDir, { recursive: true });
|
||||
|
||||
const firstSessionId = 'session-reused-a';
|
||||
const secondSessionId = 'session-reused-b';
|
||||
const firstFile = join(currentSessionsDir, `codex-${firstSessionId}.jsonl`);
|
||||
const secondFile = join(currentSessionsDir, `codex-${secondSessionId}.jsonl`);
|
||||
const oldTimestamp = new Date(startupTimestampMs - 10 * 60 * 1000).toISOString();
|
||||
|
||||
await writeFile(
|
||||
firstFile,
|
||||
JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id: firstSessionId, cwd: targetCwd, timestamp: oldTimestamp }
|
||||
}) + '\n'
|
||||
);
|
||||
await writeFile(
|
||||
secondFile,
|
||||
JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id: secondSessionId, cwd: targetCwd, timestamp: oldTimestamp }
|
||||
}) + '\n'
|
||||
);
|
||||
|
||||
let matchedSessionId: string | null = null;
|
||||
scanner = await createCodexSessionScanner({
|
||||
sessionId: null,
|
||||
cwd: targetCwd,
|
||||
startupTimestampMs,
|
||||
onEvent: (event) => events.push(event),
|
||||
onSessionFound: (sessionId) => {
|
||||
matchedSessionId = sessionId;
|
||||
}
|
||||
});
|
||||
|
||||
await wait(150);
|
||||
expect(matchedSessionId).toBeNull();
|
||||
|
||||
const firstNewLine = JSON.stringify({
|
||||
type: 'response_item',
|
||||
payload: { type: 'function_call', name: 'Tool', call_id: 'call-reused-a-1', arguments: '{}' }
|
||||
});
|
||||
const secondNewLine = JSON.stringify({
|
||||
type: 'response_item',
|
||||
payload: { type: 'function_call', name: 'Tool', call_id: 'call-reused-b-1', arguments: '{}' }
|
||||
});
|
||||
await appendFile(firstFile, firstNewLine + '\n');
|
||||
await appendFile(secondFile, secondNewLine + '\n');
|
||||
|
||||
await wait(2300);
|
||||
expect(matchedSessionId).toBeNull();
|
||||
expect(events).toHaveLength(0);
|
||||
|
||||
const laterUniqueLine = JSON.stringify({
|
||||
type: 'response_item',
|
||||
payload: { type: 'function_call', name: 'Tool', call_id: 'call-reused-a-2', arguments: '{}' }
|
||||
});
|
||||
await appendFile(firstFile, laterUniqueLine + '\n');
|
||||
|
||||
await wait(2300);
|
||||
expect(matchedSessionId).toBeNull();
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +79,10 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
|
||||
private reportedSessionId: string | null;
|
||||
private matchFailed = false;
|
||||
private bestWithinWindow: Candidate | null = null;
|
||||
private readonly recentActivitySessionIds = new Set<string>();
|
||||
private firstRecentActivityCandidateResolved = false;
|
||||
private readonly firstRecentActivitySessionIds = new Set<string>();
|
||||
private loggedAmbiguousRecentActivity = false;
|
||||
|
||||
constructor(opts: CodexSessionScannerOptions, targetCwd: string | null) {
|
||||
super({ intervalMs: 2000 });
|
||||
@@ -140,6 +144,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
|
||||
|
||||
protected async beforeScan(): Promise<void> {
|
||||
this.bestWithinWindow = null;
|
||||
this.recentActivitySessionIds.clear();
|
||||
}
|
||||
|
||||
protected async findSessionFiles(): Promise<string[]> {
|
||||
@@ -172,6 +177,10 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
|
||||
this.bestWithinWindow = candidate;
|
||||
}
|
||||
}
|
||||
const recentActivityCandidate = this.getRecentActivityCandidateForFile(filePath, stats.newCount);
|
||||
if (recentActivityCandidate) {
|
||||
this.recentActivitySessionIds.add(recentActivityCandidate.sessionId);
|
||||
}
|
||||
if (stats.newCount > 0) {
|
||||
logger.debug(`[CODEX_SESSION_SCANNER] Buffered ${stats.newCount} pending events from ${filePath}`);
|
||||
}
|
||||
@@ -189,18 +198,50 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
|
||||
if (this.bestWithinWindow) {
|
||||
logger.debug(`[CODEX_SESSION_SCANNER] Selected session ${this.bestWithinWindow.sessionId} within start window`);
|
||||
this.setActiveSessionId(this.bestWithinWindow.sessionId);
|
||||
} else if (Date.now() > this.matchDeadlineMs) {
|
||||
this.matchFailed = true;
|
||||
this.pendingEventsByFile.clear();
|
||||
const message = `No Codex session found within ${this.sessionStartWindowMs}ms for cwd ${this.targetCwd}; refusing fallback.`;
|
||||
logger.warn(`[CODEX_SESSION_SCANNER] ${message}`);
|
||||
this.onSessionMatchFailed?.(message);
|
||||
} else if (this.pendingEventsByFile.size > 0) {
|
||||
logger.debug('[CODEX_SESSION_SCANNER] No session candidate matched yet; pending events buffered');
|
||||
} else {
|
||||
this.captureFirstRecentActivityCandidate();
|
||||
|
||||
if (this.firstRecentActivitySessionIds.size === 1) {
|
||||
const [sessionId] = this.firstRecentActivitySessionIds;
|
||||
if (sessionId) {
|
||||
logger.debug(`[CODEX_SESSION_SCANNER] Selected session ${sessionId} from first unique matching activity after startup`);
|
||||
this.setActiveSessionId(sessionId);
|
||||
}
|
||||
} else if (
|
||||
!this.loggedAmbiguousRecentActivity
|
||||
&& this.firstRecentActivityCandidateResolved
|
||||
&& this.firstRecentActivitySessionIds.size > 1
|
||||
) {
|
||||
this.loggedAmbiguousRecentActivity = true;
|
||||
logger.debug('[CODEX_SESSION_SCANNER] First matching activity after startup was ambiguous; refusing reused-session adoption');
|
||||
}
|
||||
|
||||
if (!this.activeSessionId) {
|
||||
if (Date.now() > this.matchDeadlineMs) {
|
||||
this.matchFailed = true;
|
||||
this.pendingEventsByFile.clear();
|
||||
const message = `No Codex session found within ${this.sessionStartWindowMs}ms for cwd ${this.targetCwd}; refusing fallback.`;
|
||||
logger.warn(`[CODEX_SESSION_SCANNER] ${message}`);
|
||||
this.onSessionMatchFailed?.(message);
|
||||
} else if (this.pendingEventsByFile.size > 0) {
|
||||
logger.debug('[CODEX_SESSION_SCANNER] No session candidate matched yet; pending events buffered');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private captureFirstRecentActivityCandidate(): void {
|
||||
if (this.firstRecentActivityCandidateResolved || this.recentActivitySessionIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.firstRecentActivityCandidateResolved = true;
|
||||
for (const sessionId of this.recentActivitySessionIds) {
|
||||
this.firstRecentActivitySessionIds.add(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private shouldSkipFile(filePath: string): boolean {
|
||||
if (!this.activeSessionId) {
|
||||
return false;
|
||||
@@ -351,6 +392,27 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
|
||||
};
|
||||
}
|
||||
|
||||
private getRecentActivityCandidateForFile(filePath: string, newCount: number): Candidate | null {
|
||||
if (newCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionId = this.sessionIdByFile.get(filePath);
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileCwd = this.sessionCwdByFile.get(filePath);
|
||||
if (this.targetCwd && fileCwd !== this.targetCwd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
score: 0
|
||||
};
|
||||
}
|
||||
|
||||
private getFilesForSession(sessionId: string): string[] {
|
||||
const matches: string[] = [];
|
||||
for (const [filePath, storedSessionId] of this.sessionIdByFile.entries()) {
|
||||
|
||||
Reference in New Issue
Block a user