diff --git a/src/main/ai-vault/codex-session-root-dedup.test.ts b/src/main/ai-vault/codex-session-root-dedup.test.ts index cf61e3808c34..b2a1618b826b 100644 --- a/src/main/ai-vault/codex-session-root-dedup.test.ts +++ b/src/main/ai-vault/codex-session-root-dedup.test.ts @@ -12,6 +12,9 @@ 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' +const CUSTOM_HOME = '/Users/ada/private-codex' +const CUSTOM_HOME_ROLLOUT = `${CUSTOM_HOME}/sessions/2026/07/01/rollout-2026-07-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl` + function codexSession(overrides: Partial): AiVaultSession { return { id: `local:codex:${overrides.sessionId ?? 'session-1'}:${overrides.filePath ?? '/tmp/x.jsonl'}`, @@ -286,6 +289,43 @@ describe('dedupeCodexRolloutCopyAliases', () => { expect(readSessionMetaId).toHaveBeenCalledTimes(2) }) + it('keeps a diverged copy for the parser while collapsing same-size copies', async () => { + type SizedCandidate = Candidate & { sizeBytes?: number } + const sizedAccessors = { + ...accessors, + getSizeBytes: (candidate: SizedCandidate) => candidate.sizeBytes + } + const staleManaged = { + agent: 'codex', + path: MANAGED_HOME_ROLLOUT, + codexHome: MANAGED_HOME, + sizeBytes: 100 + } + const newerCustom = { + agent: 'codex', + path: CUSTOM_HOME_ROLLOUT, + codexHome: CUSTOM_HOME, + sizeBytes: 300 + } + const readSessionMetaId = async () => 'shared-session-id' + + for (const order of [ + [staleManaged, newerCustom], + [newerCustom, staleManaged] + ]) { + await expect( + dedupeCodexRolloutCopyAliases(order, sizedAccessors, readSessionMetaId) + ).resolves.toEqual(order) + } + await expect( + dedupeCodexRolloutCopyAliases( + [{ ...newerCustom, sizeBytes: 100 }, staleManaged], + sizedAccessors, + readSessionMetaId + ) + ).resolves.toEqual([staleManaged]) + }) + // Why: the proof reads run inside the scan's 130s deadline, so a superseded // scan must stop rather than drain a whole second history copy (#17888). it('stops proving copies once the scan is cancelled', async () => { @@ -402,6 +442,34 @@ describe('dedupeScannedSessions', () => { expect(dedupeScannedSessions([wslReal, wslManaged])).toEqual([wslManaged]) }) + it('shows the copy with later activity over a stale managed-home copy', () => { + const staleManaged = codexSession({ + filePath: MANAGED_HOME_ROLLOUT, + codexHome: MANAGED_HOME, + updatedAt: '2026-07-01T10:05:00.000Z', + messageCount: 2 + }) + const newerCustom = codexSession({ + filePath: CUSTOM_HOME_ROLLOUT, + codexHome: CUSTOM_HOME, + updatedAt: '2026-07-01T11:00:00.000Z', + messageCount: 6 + }) + expect(dedupeScannedSessions([staleManaged, newerCustom])).toEqual([newerCustom]) + expect(dedupeScannedSessions([newerCustom, staleManaged])).toEqual([newerCustom]) + }) + + it('keeps the managed home when copies have the same activity', () => { + const managed = codexSession({ filePath: MANAGED_HOME_ROLLOUT, codexHome: MANAGED_HOME }) + const custom = codexSession({ + filePath: CUSTOM_HOME_ROLLOUT, + codexHome: CUSTOM_HOME, + modifiedAt: '2026-07-02T00:00:00.000Z' + }) + expect(dedupeScannedSessions([custom, managed])).toEqual([managed]) + expect(dedupeScannedSessions([managed, custom])).toEqual([managed]) + }) + it('never collapses matching host and WSL session identities', () => { const rolloutName = REAL_HOME_ROLLOUT.split('/').at(-1) const host = codexSession({ diff --git a/src/main/ai-vault/codex-session-root-dedup.ts b/src/main/ai-vault/codex-session-root-dedup.ts index 061a05aaf8ec..7304f90623e4 100644 --- a/src/main/ai-vault/codex-session-root-dedup.ts +++ b/src/main/ai-vault/codex-session-root-dedup.ts @@ -130,6 +130,7 @@ export async function dedupeCodexRolloutAliases( getFilePath: (candidate: T) => string getCodexHome: (candidate: T) => string | null getHardlinkIdentity: (candidate: T) => string | null + getSizeBytes?: (candidate: T) => number | undefined }, readSessionMetaId: (filePath: string) => Promise, signal?: AbortSignal @@ -144,8 +145,9 @@ const COPY_PROOF_READ_CONCURRENCY = 8 /** * Drops cross-volume rollout copies only when bounded session metadata proves - * the same Codex session id. Unreadable or ambiguous candidates remain for the - * full parser and its existing post-parse identity check. + * the same Codex session id and the files are the same size. Unreadable, + * ambiguous, or diverged candidates remain for the full parser and its + * post-parse identity check, which keeps the copy with the latest activity. */ export async function dedupeCodexRolloutCopyAliases( candidates: readonly T[], @@ -153,6 +155,7 @@ export async function dedupeCodexRolloutCopyAliases( isCodex: (candidate: T) => boolean getFilePath: (candidate: T) => string getCodexHome: (candidate: T) => string | null + getSizeBytes?: (candidate: T) => number | undefined }, readSessionMetaId: (filePath: string) => Promise, signal?: AbortSignal @@ -200,22 +203,28 @@ export async function dedupeCodexRolloutCopyAliases( if (group.length < 2) { continue } - const bestById = new Map() - for (const candidate of group) { + const bestByCopy = new Map() + const copyKey = (candidate: T): string | null => { const id = idByCandidate.get(candidate) - if (!id) { + // Why size: a copy resumed in another home grows there while the original + // stays frozen; root rank alone would hide the newer turns (#22478). + return id ? `${id}\0${accessors.getSizeBytes?.(candidate) ?? ''}` : null + } + for (const candidate of group) { + const key = copyKey(candidate) + if (!key) { continue } const filePath = accessors.getFilePath(candidate) const rank = codexSessionRootRank(accessors.getCodexHome(candidate)) - const best = bestById.get(id) + const best = bestByCopy.get(key) if (!best || rank < best.rank || (rank === best.rank && filePath < best.filePath)) { - bestById.set(id, { candidate, rank, filePath }) + bestByCopy.set(key, { candidate, rank, filePath }) } } for (const candidate of group) { - const id = idByCandidate.get(candidate) - if (id && bestById.get(id)?.candidate !== candidate) { + const key = copyKey(candidate) + if (key && bestByCopy.get(key)?.candidate !== candidate) { aliasesToDrop.add(candidate) } } @@ -235,6 +244,17 @@ export function codexSessionAliasKey(session: AiVaultSession): string | null { } export function codexSessionAliasBeats(candidate: AiVaultSession, best: AiVaultSession): boolean { + // Why content time before root rank: a diverged copy's last record is later; + // identical copies tie here and keep the root preference (#22478). + const candidateActivity = candidate.updatedAt ? Date.parse(candidate.updatedAt) : Number.NaN + const bestActivity = best.updatedAt ? Date.parse(best.updatedAt) : Number.NaN + if ( + !Number.isNaN(candidateActivity) && + !Number.isNaN(bestActivity) && + candidateActivity !== bestActivity + ) { + return candidateActivity > bestActivity + } const candidateRank = codexSessionRootRank(candidate.codexHome) const bestRank = codexSessionRootRank(best.codexHome) if (candidateRank !== bestRank) { diff --git a/src/main/ai-vault/session-scan-cutoff.test.ts b/src/main/ai-vault/session-scan-cutoff.test.ts index 304078ed5f5b..c03c1503bd51 100644 --- a/src/main/ai-vault/session-scan-cutoff.test.ts +++ b/src/main/ai-vault/session-scan-cutoff.test.ts @@ -72,7 +72,13 @@ describe('canStopParsingSessions', () => { } const sessions = collection([session(100, { ...alias, codexHome: '/custom' }), session(100)]) expect(canStopParsingSessions(sessions, 2, 50)).toBe(true) - const preferred = session(10, { ...alias, codexHome: null }) + // No transcript activity time, so root rank decides and the mtime fallback lowers it. + const preferred = session(10, { + ...alias, + codexHome: null, + updatedAt: null, + modifiedAt: new Date(10).toISOString() + }) sessions.add(preferred) expect(sessions.size).toBe(2) expect(canStopParsingSessions(sessions, 2, 50)).toBe(false) diff --git a/src/main/ai-vault/session-scanner-candidates.ts b/src/main/ai-vault/session-scanner-candidates.ts index 80f146367e54..9b2de6cd97b1 100644 --- a/src/main/ai-vault/session-scanner-candidates.ts +++ b/src/main/ai-vault/session-scanner-candidates.ts @@ -38,7 +38,8 @@ export async function sessionCandidatesFromDiscoveries( isCodex: (candidate) => candidate.agent === 'codex', getFilePath: (candidate) => candidate.file.path, getCodexHome: (candidate) => candidate.codexHome, - getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file) + getHardlinkIdentity: (candidate) => codexRolloutHardlinkIdentity(candidate.file), + getSizeBytes: (candidate) => candidate.file.sizeBytes }, (filePath) => readCodexRolloutSessionMetaId(filePath, options.signal, 'scan'), options.signal