diff --git a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts index 6b8ef0c0..7da031ed 100644 --- a/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts +++ b/packages/agent-manager/src/__tests__/adapters/CodexAdapter.test.ts @@ -95,6 +95,11 @@ describe('CodexAdapter', () => { }); describe('detectAgents', () => { + async function useRealSessionMatcher(): Promise { + const actualMatching = await vi.importActual('../../utils/matching.js'); + mockedMatchProcessesToSessions.mockImplementation(actualMatching.matchProcessesToSessions); + } + it('should return empty list when no codex process is running', async () => { mockedListAgentProcesses.mockReturnValue([]); @@ -190,6 +195,163 @@ describe('CodexAdapter', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + it('should match when session_meta timestamp and file birthtime both align with process start', async () => { + await useRealSessionMatcher(); + const processStart = new Date('2026-03-18T15:00:00.000Z'); + const sessionTimestamp = '2026-03-18T15:00:05.000Z'; + const processes: ProcessInfo[] = [ + { + pid: 101, + command: 'codex', + cwd: '/repo-a', + tty: 'ttys001', + startTime: processStart, + }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-')); + const sessionsDir = path.join(tmpDir, 'sessions'); + const dateDir = path.join(sessionsDir, '2026', '03', '18'); + fs.mkdirSync(dateDir, { recursive: true }); + + const sessionFile = path.join(dateDir, 'sess-aligned.jsonl'); + fs.writeFileSync(sessionFile, [ + JSON.stringify({ type: 'session_meta', payload: { id: 'sess-aligned', timestamp: sessionTimestamp, cwd: '/repo-a' } }), + JSON.stringify({ type: 'event', timestamp: sessionTimestamp, payload: { type: 'token_count', message: 'Aligned session' } }), + ].join('\n')); + + (adapter as any).codexSessionsDir = sessionsDir; + mockedBatchGetSessionFileBirthtimes.mockReturnValue([ + { + sessionId: 'sess-aligned', + filePath: sessionFile, + projectDir: dateDir, + birthtimeMs: new Date(sessionTimestamp).getTime(), + resolvedCwd: '', + }, + ]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + pid: 101, + sessionId: 'sess-aligned', + summary: 'Aligned session', + }); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should match late-created session files using session_meta timestamp', async () => { + await useRealSessionMatcher(); + const processStart = new Date('2026-03-18T15:00:00.000Z'); + const sessionTimestamp = '2026-03-18T15:00:10.000Z'; + const lateBirthtime = new Date('2026-03-18T15:05:30.000Z').getTime(); + const processes: ProcessInfo[] = [ + { + pid: 102, + command: 'codex', + cwd: '/repo-a', + tty: 'ttys001', + startTime: processStart, + }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-')); + const sessionsDir = path.join(tmpDir, 'sessions'); + const dateDir = path.join(sessionsDir, '2026', '03', '18'); + fs.mkdirSync(dateDir, { recursive: true }); + + const sessionFile = path.join(dateDir, 'sess-late.jsonl'); + fs.writeFileSync(sessionFile, [ + JSON.stringify({ type: 'session_meta', payload: { id: 'sess-late', timestamp: sessionTimestamp, cwd: '/repo-a' } }), + JSON.stringify({ type: 'event', timestamp: sessionTimestamp, payload: { type: 'token_count', message: 'Late file session' } }), + ].join('\n')); + + (adapter as any).codexSessionsDir = sessionsDir; + mockedBatchGetSessionFileBirthtimes.mockReturnValue([ + { + sessionId: 'sess-late', + filePath: sessionFile, + projectDir: dateDir, + birthtimeMs: lateBirthtime, + resolvedCwd: '', + }, + ]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + pid: 102, + sessionId: 'sess-late', + summary: 'Late file session', + }); + expect(mockedMatchProcessesToSessions.mock.calls[0][1][0].birthtimeMs).toBe(new Date(sessionTimestamp).getTime()); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it.each<[string, string | undefined]>([ + ['missing', undefined], + ['invalid', 'not-a-date'], + ])('should fall back to file birthtime when session_meta timestamp is %s', async (_label, metaTimestamp) => { + await useRealSessionMatcher(); + const processStart = new Date('2026-03-18T15:00:00.000Z'); + const fileBirthtime = new Date('2026-03-18T15:00:20.000Z').getTime(); + const processes: ProcessInfo[] = [ + { + pid: 103, + command: 'codex', + cwd: '/repo-a', + tty: 'ttys001', + startTime: processStart, + }, + ]; + mockedListAgentProcesses.mockReturnValue(processes); + mockedEnrichProcesses.mockReturnValue(processes); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-test-')); + const sessionsDir = path.join(tmpDir, 'sessions'); + const dateDir = path.join(sessionsDir, '2026', '03', '18'); + fs.mkdirSync(dateDir, { recursive: true }); + + const payload: { id: string; cwd: string; timestamp?: string } = { id: `sess-${_label}`, cwd: '/repo-a' }; + if (metaTimestamp !== undefined) { + payload.timestamp = metaTimestamp; + } + + const sessionFile = path.join(dateDir, `sess-${_label}.jsonl`); + fs.writeFileSync(sessionFile, [ + JSON.stringify({ type: 'session_meta', payload }), + JSON.stringify({ type: 'event', timestamp: '2026-03-18T15:00:30.000Z', payload: { type: 'token_count', message: `${_label} timestamp session` } }), + ].join('\n')); + + (adapter as any).codexSessionsDir = sessionsDir; + mockedBatchGetSessionFileBirthtimes.mockReturnValue([ + { + sessionId: `sess-${_label}`, + filePath: sessionFile, + projectDir: dateDir, + birthtimeMs: fileBirthtime, + resolvedCwd: '', + }, + ]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0].sessionId).toBe(`sess-${_label}`); + expect(mockedMatchProcessesToSessions.mock.calls[0][1][0].birthtimeMs).toBe(fileBirthtime); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + it('should fall back to process-only for unmatched processes', async () => { const processes: ProcessInfo[] = [ { pid: 100, command: 'codex', cwd: '/repo-a', tty: 'ttys001', startTime: new Date() }, @@ -602,6 +764,133 @@ describe('CodexAdapter', () => { const { sessions } = discoverSessions(processes); expect(sessions[0].resolvedCwd).toBe(''); }); + + it('should use session_meta timestamp as the matching birthtime when valid', () => { + const sessionsDir = path.join(tmpDir, 'sessions'); + (adapter as any).codexSessionsDir = sessionsDir; + const discoverSessions = (adapter as any).discoverSessions.bind(adapter); + + const dateDir = path.join(sessionsDir, '2026', '03', '18'); + fs.mkdirSync(dateDir, { recursive: true }); + + const sessionFile = path.join(dateDir, 'sess-meta-time.jsonl'); + const metaTimestamp = '2026-03-18T15:00:05.000Z'; + fs.writeFileSync(sessionFile, + JSON.stringify({ type: 'session_meta', payload: { id: 'sess-meta-time', timestamp: metaTimestamp, cwd: '/repo-a' } }), + ); + + mockedBatchGetSessionFileBirthtimes.mockReturnValue([ + { + sessionId: 'sess-meta-time', + filePath: sessionFile, + projectDir: dateDir, + birthtimeMs: new Date('2026-03-18T15:05:30.000Z').getTime(), + resolvedCwd: '', + }, + ]); + + const { sessions } = discoverSessions([ + { pid: 1, command: 'codex', cwd: '/repo-a', tty: '', startTime: new Date('2026-03-18T15:00:00Z') }, + ]); + + expect(sessions[0].resolvedCwd).toBe('/repo-a'); + expect(sessions[0].birthtimeMs).toBe(new Date(metaTimestamp).getTime()); + }); + + it('should tolerate malformed session_meta and unreadable files', () => { + const sessionsDir = path.join(tmpDir, 'sessions'); + (adapter as any).codexSessionsDir = sessionsDir; + const discoverSessions = (adapter as any).discoverSessions.bind(adapter); + + const dateDir = path.join(sessionsDir, '2026', '03', '18'); + fs.mkdirSync(dateDir, { recursive: true }); + + const malformedFile = path.join(dateDir, 'malformed.jsonl'); + const missingFile = path.join(dateDir, 'missing.jsonl'); + fs.writeFileSync(malformedFile, '{not valid json'); + + mockedBatchGetSessionFileBirthtimes.mockReturnValue([ + { + sessionId: 'malformed', + filePath: malformedFile, + projectDir: dateDir, + birthtimeMs: 1710800324000, + resolvedCwd: '', + }, + { + sessionId: 'missing', + filePath: missingFile, + projectDir: dateDir, + birthtimeMs: 1710800325000, + resolvedCwd: '', + }, + ]); + + const result = discoverSessions([ + { pid: 1, command: 'codex', cwd: '/repo', tty: '', startTime: new Date('2026-03-18T15:00:00Z') }, + ]); + + expect(result.sessions).toHaveLength(2); + expect(result.sessions[0].resolvedCwd).toBe(''); + expect(result.sessions[1].resolvedCwd).toBe(''); + expect(result.contentCache.has(malformedFile)).toBe(true); + expect(result.contentCache.has(missingFile)).toBe(false); + }); + }); + + describe('findSessionFileById', () => { + let tmpDir: string; + let sessionsDir: string; + const sessionId = 'aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-resume-find-')); + sessionsDir = path.join(tmpDir, 'sessions'); + fs.mkdirSync(path.join(sessionsDir, '2026', '03', '18'), { recursive: true }); + (adapter as any).codexSessionsDir = sessionsDir; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeResumeSession(timestamp?: string): string { + const filePath = path.join(sessionsDir, '2026', '03', '18', `${sessionId}.jsonl`); + const payload: { id: string; cwd: string; timestamp?: string } = { id: sessionId, cwd: '/repo-a' }; + if (timestamp !== undefined) { + payload.timestamp = timestamp; + } + fs.writeFileSync(filePath, JSON.stringify({ type: 'session_meta', payload })); + return filePath; + } + + it('should return session_meta timestamp as birthtimeMs when valid', () => { + const metaTimestamp = '2026-03-18T15:00:05.000Z'; + writeResumeSession(metaTimestamp); + const findSessionFileById = (adapter as any).findSessionFileById.bind(adapter); + + const session = findSessionFileById(sessionId); + + expect(session).toMatchObject({ + sessionId, + resolvedCwd: '/repo-a', + birthtimeMs: new Date(metaTimestamp).getTime(), + }); + }); + + it.each<[string, string | undefined]>([ + ['missing', undefined], + ['invalid', 'not-a-date'], + ])('should fall back to stat birthtimeMs when session_meta timestamp is %s', (_label, metaTimestamp) => { + const sessionFile = writeResumeSession(metaTimestamp); + const stat = fs.statSync(sessionFile); + const findSessionFileById = (adapter as any).findSessionFileById.bind(adapter); + + const session = findSessionFileById(sessionId); + + expect(session).not.toBeNull(); + expect(session.birthtimeMs).toBe(stat.birthtimeMs); + }); }); describe('helper methods', () => { diff --git a/packages/agent-manager/src/adapters/CodexAdapter.ts b/packages/agent-manager/src/adapters/CodexAdapter.ts index e895830c..ca1f823e 100644 --- a/packages/agent-manager/src/adapters/CodexAdapter.ts +++ b/packages/agent-manager/src/adapters/CodexAdapter.ts @@ -215,13 +215,14 @@ export class CodexAdapter implements AgentAdapter { const stat = safeStat(filePath); if (!stat) continue; + const metaTimestampMs = this.parseMetaTimestampMs(parsed.payload?.timestamp); return { sessionId, filePath, projectDir: path.dirname(filePath), - birthtimeMs: stat.birthtimeMs, - resolvedCwd: parsed.payload.cwd || '', + birthtimeMs: metaTimestampMs ?? stat.birthtimeMs, + resolvedCwd: parsed.payload?.cwd || '', }; } catch { continue; @@ -290,6 +291,10 @@ export class CodexAdapter implements AgentAdapter { const parsed = JSON.parse(firstLine); if (parsed.type === 'session_meta') { file.resolvedCwd = parsed.payload?.cwd || ''; + const metaTimestampMs = this.parseMetaTimestampMs(parsed.payload?.timestamp); + if (metaTimestampMs !== null) { + file.birthtimeMs = metaTimestampMs; + } } } } catch { @@ -468,6 +473,16 @@ export class CodexAdapter implements AgentAdapter { return Number.isNaN(timestamp.getTime()) ? null : timestamp; } + private parseMetaTimestampMs(value?: string): number | null { + if (typeof value !== 'string') return null; + + const timestamp = this.parseTimestamp(value); + if (!timestamp) return null; + + const timestampMs = timestamp.getTime(); + return Number.isFinite(timestampMs) ? timestampMs : null; + } + private determineStatus(session: CodexSession): AgentStatus { const diffMs = Date.now() - session.lastActive.getTime(); const diffMinutes = diffMs / 60000;