mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
feat(codex): collapse duplicate session listings across Codex roots
Backfilled/bridged rollouts are hardlinked into both the real ~/.codex and Orca's managed runtime home, so AI Vault listed each session once per root (#7521). Dedup candidates by rollout file name pre-parse and parsed sessions by session id post-parse, keeping the canonical root: host real home first (unprefixed resume), then the managed runtime home, then other homes. Applies to local, WSL, and SSH-remote scans.
This commit is contained in:
@@ -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>): 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])
|
||||
})
|
||||
})
|
||||
@@ -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-<timestamp>-<session uuid>.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<T>(
|
||||
candidates: readonly T[],
|
||||
accessors: {
|
||||
isCodex: (candidate: T) => boolean
|
||||
getFilePath: (candidate: T) => string
|
||||
getCodexHome: (candidate: T) => string | null
|
||||
}
|
||||
): T[] {
|
||||
const bestByFileName = new Map<string, { candidate: T; rank: number; filePath: string }>()
|
||||
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<string, AiVaultSession>()
|
||||
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
|
||||
}
|
||||
@@ -156,6 +156,7 @@ function remoteCodexSources(
|
||||
].map((codexHome) => ({
|
||||
agent: 'codex',
|
||||
rootDir: joinRemotePath(hostPlatform, codexHome, 'sessions'),
|
||||
codexHome,
|
||||
extensions: ['.jsonl'],
|
||||
parse: (file, content, context) =>
|
||||
parseCodexSessionContent({
|
||||
|
||||
@@ -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 `<session>/subagents/*.jsonl` siblings from the walked
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'`
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user