From abb2c05e9a30a59f473239ad80eb83932d77c620 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 24 Sep 2026 00:30:00 -0400 Subject: [PATCH] fix(ai-vault): show the newer copy of a Codex session over a stale managed-home copy When one Codex conversation exists in two homes and the copies have diverged, list and resume the copy with the latest activity instead of always preferring Orca's managed home. Identical copies (hardlinks, same-size copies) keep the managed-home preference. Fixes #22478 --- .../ai-vault/codex-session-root-dedup.test.ts | 68 +++++++++++++++++++ src/main/ai-vault/codex-session-root-dedup.ts | 38 ++++++++--- src/main/ai-vault/session-scan-cutoff.test.ts | 8 ++- .../ai-vault/session-scanner-candidates.ts | 3 +- 4 files changed, 106 insertions(+), 11 deletions(-) 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