Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/main/ai-vault/codex-session-root-dedup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): AiVaultSession {
return {
id: `local:codex:${overrides.sessionId ?? 'session-1'}:${overrides.filePath ?? '/tmp/x.jsonl'}`,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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({
Expand Down
38 changes: 29 additions & 9 deletions src/main/ai-vault/codex-session-root-dedup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export async function dedupeCodexRolloutAliases<T>(
getFilePath: (candidate: T) => string
getCodexHome: (candidate: T) => string | null
getHardlinkIdentity: (candidate: T) => string | null
getSizeBytes?: (candidate: T) => number | undefined
},
readSessionMetaId: (filePath: string) => Promise<string | null>,
signal?: AbortSignal
Expand All @@ -144,15 +145,17 @@ 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<T>(
candidates: readonly T[],
accessors: {
isCodex: (candidate: T) => boolean
getFilePath: (candidate: T) => string
getCodexHome: (candidate: T) => string | null
getSizeBytes?: (candidate: T) => number | undefined
},
readSessionMetaId: (filePath: string) => Promise<string | null>,
signal?: AbortSignal
Expand Down Expand Up @@ -200,22 +203,28 @@ export async function dedupeCodexRolloutCopyAliases<T>(
if (group.length < 2) {
continue
}
const bestById = new Map<string, { candidate: T; rank: number; filePath: string }>()
for (const candidate of group) {
const bestByCopy = new Map<string, { candidate: T; rank: number; filePath: string }>()
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not discard same-size transcripts before comparing their content.

Two homes can contain different records for the same session ID and still have equal file sizes. In that case, bestByCopy drops one transcript before parsing, even if the dropped transcript has later activity. The equal-size assertion in src/main/ai-vault/codex-session-root-dedup.test.ts preserves this behavior. Keep both candidates for post-parse selection unless content equality is established.

}
Comment on lines +207 to +212

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Byte-size equality is only a proxy for divergence, so two copies whose content differs but happens to match in size are still collapsed to the ranked (possibly stale) copy before the parser can compare activity. Append-only growth makes this unlikely, but it is a residual hole in "show the newer copy" — worth calling out as an accepted limitation rather than an oversight.

Technical details
# Size-equality divergence proxy

## Affected sites
- `src/main/ai-vault/codex-session-root-dedup.ts:207-212` — `copyKey` collapses by `id + size`; matching sizes produce one key and the rank loser is dropped pre-parse.

## Required outcome
- No change required if byte-size growth is accepted as the divergence signal; document the residual case or key the collapse on something the parser can confirm.

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)
}
}
Expand All @@ -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
Comment on lines +251 to +256

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Make alias selection independent of input order.

This comparison is non-transitive when one copy lacks updatedAt. Consider an older ~/.codex copy, a managed copy with no activity time, and a newer custom-home copy. In the order custom, managed, ~/.codex, root rank selects managed over custom and then ~/.codex over managed. In another order, the newer custom copy wins. Select a winner per alias group using a consistent rule for missing activity times, and cover this three-copy case in multiple orders.

}
Comment on lines +247 to +257

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comparison is symmetric, so a newer managed-home copy now outranks the system real-home row, not just the custom-home case the PR targets. On AI Vault resume the managed winner then reaches prepareLegacySharedCodexSessionResume, which materializes the rollout into ~/.codex; when a diverged file already occupies that target, assertMatchingExistingTarget throws Retry resume. Please confirm this managed↔real-home direction is acceptable.

Technical details
# Managed-home winner routes into the legacy migration guard

## Affected sites
- `src/main/ai-vault/codex-session-root-dedup.ts:249-257` — activity now outranks `codexSessionRootRank` in both directions, so the managed home can win over the real-home row.
- `src/main/codex/codex-legacy-session-resume.ts:45-53` — materialization fires when the winner's `codexHome` equals the managed home and the host is on the real-home lane.
- `src/main/codex/codex-legacy-session-resume.ts:192-203` — `assertMatchingExistingTarget` throws when the target exists and is not the same inode/content.
- `src/main/runtime/rpc/methods/ai-vault.ts:112` — the AI Vault RPC surfaces that throw to the renderer; the launch path (`src/main/startup/codex-session-resume-launch.ts:66-81`) catches it and falls back to the origin home.

## Required outcome
- Decide whether a diverged managed-vs-real-home pair should resume the newer managed copy in place (`useRealCodexHome: false`) rather than fail the migration, or treat the guarded failure as the intended outcome.

## Open questions for the human
- Is a diverged managed/real-home pair (as opposed to the custom-home pair in the PR description) expected to occur? If so, is a resume error preferable to the previous silently-stale resume?

const candidateRank = codexSessionRootRank(candidate.codexHome)
const bestRank = codexSessionRootRank(best.codexHome)
if (candidateRank !== bestRank) {
Expand Down
8 changes: 7 additions & 1 deletion src/main/ai-vault/session-scan-cutoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/main/ai-vault/session-scanner-candidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading