diff --git a/src/main/ai-vault/codex-session-root-dedup.test.ts b/src/main/ai-vault/codex-session-root-dedup.test.ts new file mode 100644 index 00000000000..4d710e368bf --- /dev/null +++ b/src/main/ai-vault/codex-session-root-dedup.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { + dedupeCodexRolloutFileAliases, + dedupeCodexSessionsBySessionId +} from './codex-session-root-dedup' + +const REAL_HOME_ROLLOUT = + '/Users/ada/.codex/sessions/2026/07/01/rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl' +const MANAGED_HOME_ROLLOUT = + '/Users/ada/Library/Application Support/orca/codex-runtime-home/home/sessions/2026/07/01/rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl' +const MANAGED_HOME = '/Users/ada/Library/Application Support/orca/codex-runtime-home/home' + +function codexSession(overrides: Partial): AiVaultSession { + return { + id: `local:codex:${overrides.sessionId ?? 'session-1'}:${overrides.filePath ?? '/tmp/x.jsonl'}`, + executionHostId: 'local', + agent: 'codex', + sessionId: 'session-1', + title: 'Session', + cwd: '/repo/app', + branch: null, + model: null, + filePath: '/tmp/x.jsonl', + codexHome: null, + createdAt: '2026-07-01T10:00:00.000Z', + updatedAt: '2026-07-01T10:05:00.000Z', + modifiedAt: '2026-07-01T10:05:00.000Z', + messageCount: 1, + totalTokens: 10, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'codex resume session-1', + subagent: null, + ...overrides + } as AiVaultSession +} + +describe('dedupeCodexRolloutFileAliases', () => { + type Candidate = { agent: string; path: string; codexHome: string | null } + const accessors = { + isCodex: (candidate: Candidate) => candidate.agent === 'codex', + getFilePath: (candidate: Candidate) => candidate.path, + getCodexHome: (candidate: Candidate) => candidate.codexHome + } + + it('keeps the real-home alias when the same rollout exists in both roots', () => { + const managed = { agent: 'codex', path: MANAGED_HOME_ROLLOUT, codexHome: MANAGED_HOME } + const real = { agent: 'codex', path: REAL_HOME_ROLLOUT, codexHome: null } + expect(dedupeCodexRolloutFileAliases([managed, real], accessors)).toEqual([real]) + expect(dedupeCodexRolloutFileAliases([real, managed], accessors)).toEqual([real]) + }) + + it('prefers the managed runtime home over other non-default homes', () => { + const managed = { agent: 'codex', path: MANAGED_HOME_ROLLOUT, codexHome: MANAGED_HOME } + const wslReal = { + agent: 'codex', + path: `\\\\wsl$\\Ubuntu\\home\\ada\\.codex\\sessions\\2026\\07\\01\\rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl`, + codexHome: '\\\\wsl$\\Ubuntu\\home\\ada\\.codex' + } + expect(dedupeCodexRolloutFileAliases([wslReal, managed], accessors)).toEqual([managed]) + }) + + it('recognizes the managed runtime home with backslash separators', () => { + const managed = { + agent: 'codex', + path: 'C:\\Users\\ada\\AppData\\Roaming\\orca\\codex-runtime-home\\home\\sessions\\2026\\07\\01\\rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl', + codexHome: 'C:\\Users\\ada\\AppData\\Roaming\\orca\\codex-runtime-home\\home' + } + const custom = { + agent: 'codex', + path: 'D:\\codex\\sessions\\2026\\07\\01\\rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl', + codexHome: 'D:\\codex' + } + expect(dedupeCodexRolloutFileAliases([custom, managed], accessors)).toEqual([managed]) + }) + + it('keeps distinct rollouts, non-codex candidates, and non-rollout file names', () => { + const real = { agent: 'codex', path: REAL_HOME_ROLLOUT, codexHome: null } + const other = { + agent: 'codex', + path: '/Users/ada/.codex/sessions/2026/07/02/rollout-2026-07-02T09-00-00-029f0000-1111-7222-8333-555555555555.jsonl', + codexHome: null + } + const oddName = { + agent: 'codex', + path: `${MANAGED_HOME}/sessions/notes.jsonl`, + codexHome: MANAGED_HOME + } + const claude = { agent: 'claude', path: REAL_HOME_ROLLOUT, codexHome: null } + expect(dedupeCodexRolloutFileAliases([real, other, oddName, claude], accessors)).toEqual([ + real, + other, + oddName, + claude + ]) + }) +}) + +describe('dedupeCodexSessionsBySessionId', () => { + it('collapses a both-roots session to the real-home row', () => { + const managed = codexSession({ + filePath: MANAGED_HOME_ROLLOUT, + codexHome: MANAGED_HOME, + id: `local:codex:session-1:${MANAGED_HOME_ROLLOUT}` + }) + const real = codexSession({ + filePath: REAL_HOME_ROLLOUT, + codexHome: null, + id: `local:codex:session-1:${REAL_HOME_ROLLOUT}` + }) + expect(dedupeCodexSessionsBySessionId([managed, real])).toEqual([real]) + expect(dedupeCodexSessionsBySessionId([real, managed])).toEqual([real]) + }) + + it('keeps managed-only and real-only sessions unchanged', () => { + const managedOnly = codexSession({ + sessionId: 'managed-only', + filePath: `${MANAGED_HOME}/sessions/2026/07/01/rollout-a.jsonl`, + codexHome: MANAGED_HOME + }) + const realOnly = codexSession({ + sessionId: 'real-only', + filePath: REAL_HOME_ROLLOUT, + codexHome: null + }) + expect(dedupeCodexSessionsBySessionId([managedOnly, realOnly])).toEqual([managedOnly, realOnly]) + }) + + it('never collapses across execution hosts or agents', () => { + const local = codexSession({ sessionId: 'session-1', executionHostId: 'local' }) + const remote = codexSession({ + sessionId: 'session-1', + executionHostId: 'ssh:build-box', + id: 'ssh:build-box:codex:session-1:/home/ada/.codex/sessions/x.jsonl' + }) + const claude = codexSession({ sessionId: 'session-1', agent: 'claude' }) + expect(dedupeCodexSessionsBySessionId([local, remote, claude])).toEqual([local, remote, claude]) + }) + + it('resolves same-rank id collisions to the newest row, then stable path order', () => { + const older = codexSession({ + sessionId: 'collision', + filePath: '/Users/ada/.codex/sessions/2026/07/01/rollout-old.jsonl', + codexHome: null, + updatedAt: '2026-07-01T10:00:00.000Z', + modifiedAt: '2026-07-01T10:00:00.000Z' + }) + const newer = codexSession({ + sessionId: 'collision', + filePath: '/Users/ada/.codex/sessions/2026/07/02/rollout-new.jsonl', + codexHome: null, + updatedAt: '2026-07-02T10:00:00.000Z', + modifiedAt: '2026-07-02T10:00:00.000Z' + }) + expect(dedupeCodexSessionsBySessionId([older, newer])).toEqual([newer]) + + const tieA = codexSession({ + sessionId: 'tie', + filePath: '/Users/ada/.codex/sessions/2026/07/01/rollout-a.jsonl', + codexHome: null + }) + const tieB = codexSession({ + sessionId: 'tie', + filePath: '/Users/ada/.codex/sessions/2026/07/01/rollout-b.jsonl', + codexHome: null + }) + expect(dedupeCodexSessionsBySessionId([tieB, tieA])).toEqual([tieA]) + }) + + it('prefers the managed runtime home over a WSL real home when no host real-home row exists', () => { + const wslManaged = codexSession({ + sessionId: 'wsl-pair', + filePath: + '\\\\wsl$\\Ubuntu\\home\\ada\\.local\\share\\orca\\codex-runtime-home\\home\\sessions\\rollout-a.jsonl', + codexHome: '\\\\wsl$\\Ubuntu\\home\\ada\\.local\\share\\orca\\codex-runtime-home\\home' + }) + const wslReal = codexSession({ + sessionId: 'wsl-pair', + filePath: '\\\\wsl$\\Ubuntu\\home\\ada\\.codex\\sessions\\rollout-a.jsonl', + codexHome: '\\\\wsl$\\Ubuntu\\home\\ada\\.codex' + }) + expect(dedupeCodexSessionsBySessionId([wslReal, wslManaged])).toEqual([wslManaged]) + }) +}) diff --git a/src/main/ai-vault/codex-session-root-dedup.ts b/src/main/ai-vault/codex-session-root-dedup.ts new file mode 100644 index 00000000000..dbb66fb674c --- /dev/null +++ b/src/main/ai-vault/codex-session-root-dedup.ts @@ -0,0 +1,116 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { sessionSortTime } from './session-scanner-accumulator' + +// Why: the session bridge and the real-home backfill hardlink one physical +// Codex rollout into multiple scanned roots (managed runtime home and the +// user's own ~/.codex), so every bridged/backfilled session used to list once +// per root (#7521). These helpers collapse those aliases to one canonical row. + +// Matches Codex rollout logs: rollout--.jsonl. The +// bridge and backfill both preserve the sessions/YYYY/MM/DD layout, so an +// identical rollout file name across Codex roots is the same session. +const CODEX_ROLLOUT_FILE_NAME_PATTERN = /^rollout-.+\.jsonl$/ + +// Why: not node:path.basename — a posix host scans remote/WSL win32 paths, so +// separators must be handled independently of the local platform. +function lastPathSegment(filePath: string): string { + return filePath.split(/[\\/]/).at(-1) ?? '' +} + +/** + * Ranks a Codex session root for canonical-alias selection, lowest wins. + * + * Host real home (null) is canonical: after the real-home flip the managed + * home's auth.json is no longer refreshed, so resume must not stamp it. The + * Orca managed runtime home still beats other homes (WSL/remote real homes, + * custom CODEX_HOMEs) because those lanes have not flipped — their launches + * keep managed auth, so resume keeps the managed stamp as today. + */ +function codexSessionRootRank(codexHome: string | null): number { + if (codexHome === null) { + return 0 + } + const segments = codexHome.split(/[\\/]/).filter(Boolean) + return segments.at(-2) === 'codex-runtime-home' && segments.at(-1) === 'home' ? 1 : 2 +} + +/** + * Drops pre-parse Codex rollout candidates that alias an already-kept rollout + * file name in a preferred root, so duplicate aliases never consume the parse + * budget or crowd the capped listing. + */ +export function dedupeCodexRolloutFileAliases( + candidates: readonly T[], + accessors: { + isCodex: (candidate: T) => boolean + getFilePath: (candidate: T) => string + getCodexHome: (candidate: T) => string | null + } +): T[] { + const bestByFileName = new Map() + for (const candidate of candidates) { + if (!accessors.isCodex(candidate)) { + continue + } + const filePath = accessors.getFilePath(candidate) + const fileName = lastPathSegment(filePath) + if (!CODEX_ROLLOUT_FILE_NAME_PATTERN.test(fileName)) { + continue + } + const rank = codexSessionRootRank(accessors.getCodexHome(candidate)) + const best = bestByFileName.get(fileName) + if (!best || rank < best.rank || (rank === best.rank && filePath < best.filePath)) { + bestByFileName.set(fileName, { candidate, rank, filePath }) + } + } + return candidates.filter((candidate) => { + if (!accessors.isCodex(candidate)) { + return true + } + const fileName = lastPathSegment(accessors.getFilePath(candidate)) + const best = bestByFileName.get(fileName) + return !best || best.candidate === candidate + }) +} + +/** + * Collapses parsed Codex sessions that share a session id on one execution + * host, keeping the canonical root's row (see codexSessionRootRank). Catches + * aliases the file-name pass cannot see: cross-volume backfill copies and + * session_meta ids that differ from the rollout file name. + */ +export function dedupeCodexSessionsBySessionId( + sessions: readonly AiVaultSession[] +): AiVaultSession[] { + const bestByKey = new Map() + for (const session of sessions) { + if (session.agent !== 'codex') { + continue + } + const key = `${session.executionHostId}:${session.sessionId}` + const best = bestByKey.get(key) + if (!best || codexSessionAliasBeats(session, best)) { + bestByKey.set(key, session) + } + } + return sessions.filter((session) => { + if (session.agent !== 'codex') { + return true + } + return bestByKey.get(`${session.executionHostId}:${session.sessionId}`) === session + }) +} + +function codexSessionAliasBeats(candidate: AiVaultSession, best: AiVaultSession): boolean { + const candidateRank = codexSessionRootRank(candidate.codexHome) + const bestRank = codexSessionRootRank(best.codexHome) + if (candidateRank !== bestRank) { + return candidateRank < bestRank + } + const candidateTime = sessionSortTime(candidate) + const bestTime = sessionSortTime(best) + if (candidateTime !== bestTime) { + return candidateTime > bestTime + } + return candidate.filePath < best.filePath +} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index c05261de31d..bc589ee769f 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -156,6 +156,7 @@ function remoteCodexSources( ].map((codexHome) => ({ agent: 'codex', rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'), + codexHome, extensions: ['.jsonl'], parse: (file, content, context) => parseCodexSessionContent({ diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index c65ef99aded..b501998e595 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -19,6 +19,9 @@ export type RemoteParserOptions = { export type RemoteSessionSource = { agent: AiVaultAgent rootDir: string + // Codex sources only: the CODEX_HOME the root belongs to, so bridged or + // backfilled rollout aliases across remote roots collapse to one canonical row. + codexHome?: string extensions: readonly string[] filePredicate?: (path: string) => boolean // Claude layout: count `/subagents/*.jsonl` siblings from the walked diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index a2280b29165..a432bef1db8 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -160,6 +160,40 @@ describe('scanRemoteAiVaultSessions', () => { }) }) + it('collapses a bridged rollout present in both remote Codex homes to one row', async () => { + const provider = new MemoryRemoteProvider() + const rolloutName = 'rollout-2026-07-04T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl' + const transcript = codexTranscript({ + sessionId: '019f0000-1111-7222-8333-444444444444', + title: 'Bridged both-homes session', + cwd: '/home/ada/repo', + timestamp: '2026-07-04T10:00:00.000Z' + }) + // Same rollout name in both homes — the in-distro bridge/backfill hardlink. + provider.addFile(`/home/ada/.codex/sessions/2026/07/04/${rolloutName}`, transcript, 3_000) + provider.addFile( + `/home/ada/.local/share/orca/codex-runtime-home/home/sessions/2026/07/04/${rolloutName}`, + transcript, + 3_000 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:build-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + // Remote lanes have not flipped to the real home: the managed runtime-home + // row stays canonical so resume keeps Orca-refreshed auth, as today. + expect(result.sessions[0]).toMatchObject({ + sessionId: '019f0000-1111-7222-8333-444444444444', + codexHome: '/home/ada/.local/share/orca/codex-runtime-home/home' + }) + }) + it('parses non-Codex transcripts through the same remote scanner', async () => { const provider = new MemoryRemoteProvider() provider.addFile( diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index cc5ae37dad3..7dddc858db1 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -11,6 +11,10 @@ import type { FileStat, IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' import { sessionSortTime } from './session-scanner-accumulator' +import { + dedupeCodexRolloutFileAliases, + dedupeCodexSessionsBySessionId +} from './codex-session-root-dedup' import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' @@ -41,21 +45,29 @@ export async function scanRemoteAiVaultSessions(args: { hostPlatform: args.hostPlatform, titleCaches: new Map() } - const candidates = ( - await mapRemoteScanConcurrently( - remoteSessionSources(args.remoteHome, args.hostPlatform), - (source) => discoverRemoteSourceCandidates({ source, context, issues }) + const candidates = dedupeCodexRolloutFileAliases( + ( + await mapRemoteScanConcurrently( + remoteSessionSources(args.remoteHome, args.hostPlatform), + (source) => discoverRemoteSourceCandidates({ source, context, issues }) + ) ) + .flat() + .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs), + { + isCodex: (candidate) => candidate.source.agent === 'codex', + getFilePath: (candidate) => candidate.file.path, + getCodexHome: (candidate) => candidate.source.codexHome ?? null + } ) - .flat() - .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs) const parsed = await parseRemoteSessionCandidates({ candidates, context, issues, limit }) - const cappedSessions = parsed.sessions + const parsedSessions = dedupeCodexSessionsBySessionId(parsed.sessions) + const cappedSessions = parsedSessions .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) .slice(0, limit) const scopePaths = normalizeRemoteScopePaths(args.scopePaths ?? []) - const parsedScopeSessions = parsed.sessions.filter((session) => + const parsedScopeSessions = parsedSessions.filter((session) => isRemoteSessionInScope(session, scopePaths) ) const extraScopeSessions = await scanRemoteInScopeSessions({ diff --git a/src/main/ai-vault/session-scanner-codex-dual-root.test.ts b/src/main/ai-vault/session-scanner-codex-dual-root.test.ts new file mode 100644 index 00000000000..d9d65b88141 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-dual-root.test.ts @@ -0,0 +1,116 @@ +import { link, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +// Scan-level coverage for the canonical-root rule when one physical Codex +// rollout is visible through both the real ~/.codex and the managed runtime +// home (the layout the session backfill and bridge produce via hardlinks). + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('scanAiVaultSessions codex dual-root dedup', () => { + it('lists a backfilled both-roots session once, attributed to the real home', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-dedup-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + // Sandbox "real ~/.codex" and Orca managed runtime home, hardlinked the + // same way the session backfill links managed rollouts into the real home. + const realHome = join(root, 'real-codex-home') + const realSessionsDir = join(realHome, 'sessions') + const managedHome = join(root, 'codex-runtime-home', 'home') + const managedSessionsDir = join(managedHome, 'sessions') + const rolloutName = 'rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl' + await mkdir(join(managedSessionsDir, '2026', '07', '01'), { recursive: true }) + await mkdir(join(realSessionsDir, '2026', '07', '01'), { recursive: true }) + + await writeFile( + join(managedSessionsDir, '2026', '07', '01', rolloutName), + jsonLines([ + { + timestamp: '2026-07-01T10:00:00.000Z', + type: 'session_meta', + payload: { id: '019f0000-1111-7222-8333-444444444444', cwd: '/repo/app' } + }, + { + timestamp: '2026-07-01T10:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Backfilled both-roots session' }] + } + } + ]) + ) + await link( + join(managedSessionsDir, '2026', '07', '01', rolloutName), + join(realSessionsDir, '2026', '07', '01', rolloutName) + ) + // A managed-only session (e.g. a backfill copy failure) must keep its + // managed-home stamp so resume still targets the home that has it. + await mkdir(join(managedSessionsDir, '2026', '07', '02'), { recursive: true }) + await writeFile( + join( + managedSessionsDir, + '2026', + '07', + '02', + 'rollout-2026-07-02T09-00-00-029f0000-1111-7222-8333-555555555555.jsonl' + ), + jsonLines([ + { + timestamp: '2026-07-02T09:00:00.000Z', + type: 'session_meta', + payload: { id: '029f0000-1111-7222-8333-555555555555', cwd: '/repo/app' } + }, + { + timestamp: '2026-07-02T09:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Managed-only session' }] + } + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + codexSessionsDir: realSessionsDir, + defaultCodexHomeDir: realHome, + additionalCodexSessionsDirs: [managedSessionsDir], + platform: 'darwin' + }) + + expect(result.issues).toEqual([]) + const codexSessions = result.sessions.filter((session) => session.agent === 'codex') + expect(codexSessions).toHaveLength(2) + + const backfilled = codexSessions.find( + (session) => session.sessionId === '019f0000-1111-7222-8333-444444444444' + ) + expect(backfilled).toMatchObject({ + codexHome: null, + filePath: join(realSessionsDir, '2026', '07', '01', rolloutName), + resumeCommand: "cd '/repo/app' && codex resume '019f0000-1111-7222-8333-444444444444'" + }) + expect(backfilled?.resumeCommand).not.toContain('CODEX_HOME') + + const managedOnly = codexSessions.find( + (session) => session.sessionId === '029f0000-1111-7222-8333-555555555555' + ) + expect(managedOnly).toMatchObject({ + codexHome: managedHome, + resumeCommand: `cd '/repo/app' && CODEX_HOME='${managedHome}' codex resume '029f0000-1111-7222-8333-555555555555'` + }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 803554316c1..6f28d7ce117 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -10,6 +10,9 @@ export type AiVaultScanOptions = { claudeProjectsDir?: string codexSessionsDir?: string additionalCodexSessionsDirs?: readonly string[] + // Why: tests inject a sandbox "real ~/.codex" so real-home attribution + // (codexHome null → unprefixed resume) is testable without the user's home. + defaultCodexHomeDir?: string wslHomeDirs?: readonly string[] geminiSessionsDir?: string copilotSessionsDir?: string diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 5ba2a95d503..caab77500f8 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -6,6 +6,10 @@ import type { import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' +import { + dedupeCodexRolloutFileAliases, + dedupeCodexSessionsBySessionId +} from './codex-session-root-dedup' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' import { createSessionParseStats, @@ -56,20 +60,30 @@ export async function scanAiVaultSessions( const parseStats = createSessionParseStats() const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) - const candidates = discoveries - .flatMap((discovery) => - discovery.files.map( - (file): SessionFileCandidate => ({ - agent: discovery.agent, - file, - codexHome: - discovery.agent === 'codex' - ? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR) - : null - }) + const candidates = dedupeCodexRolloutFileAliases( + discoveries + .flatMap((discovery) => + discovery.files.map( + (file): SessionFileCandidate => ({ + agent: discovery.agent, + file, + codexHome: + discovery.agent === 'codex' + ? codexHomeForSessionsDir( + discovery.rootDir, + options.defaultCodexHomeDir ?? DEFAULT_CODEX_HOME_DIR + ) + : null + }) + ) ) - ) - .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs) + .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs), + { + isCodex: (candidate) => candidate.agent === 'codex', + getFilePath: (candidate) => candidate.file.path, + getCodexHome: (candidate) => candidate.codexHome + } + ) const parsedSessions = await parseSessionCandidates({ candidates, @@ -80,7 +94,7 @@ export async function scanAiVaultSessions( parseStats }) - const cappedSessions = parsedSessions + const cappedSessions = dedupeCodexSessionsBySessionId(parsedSessions) .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) .slice(0, limit) diff --git a/src/shared/ai-vault-resume-command.test.ts b/src/shared/ai-vault-resume-command.test.ts index a654dd8217e..ec94a0f82d4 100644 --- a/src/shared/ai-vault-resume-command.test.ts +++ b/src/shared/ai-vault-resume-command.test.ts @@ -29,6 +29,21 @@ describe('buildAiVaultResumeCommand', () => { ) }) + it('emits no CODEX_HOME stamp for real-home canonical sessions', () => { + // Backfilled sessions dedupe to the real-home row (codexHome null); their + // resume must run against the user's own ~/.codex, never the frozen + // managed home whose auth.json stops refreshing after the flip. + const command = buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: '/repo/app', + platform: 'darwin', + codexHome: null + }) + expect(command).toBe("cd '/repo/app' && codex resume 'session-1'") + expect(command).not.toContain('CODEX_HOME') + }) + it('carries non-default Codex homes in copied resume commands', () => { expect( buildAiVaultResumeCommand({