Skip to content

Commit 73dba96

Browse files
committed
fix(cli): canonicalize backfill file paths via realpath to stop symlinked-home duplicate rollups
Rollup identity hashes the absolute file path, so the same physical session file seen through a symlinked agent home (e.g. hook running with CODEX_HOME=~/.codex-work -> ~/.codex while a manual sync uses the real path) uploaded duplicate rollups. Resolve every listed file to its physical path at the listBackfillSourceFiles chokepoint and drop same-run canonical-path duplicates. Keying by source+sessionId instead was rejected: sessionId comes from file content and legitimately repeats across files (Claude Code subagent transcripts), so dropping the path would clobber them. Schema v6->v7 re-parses history on next sync; users with symlinked homes must run `sync --purge` once since their rollup keys change. Refs: report by saphitv (multi-account .codex-t3 shadow homes)
1 parent cf9b0b4 commit 73dba96

3 files changed

Lines changed: 71 additions & 11 deletions

File tree

packages/cli/src/cli.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
import type { BackfillSourceDefinition } from './lib/backfill.js'
99
import type { BackfillImportCounts, BackfillIncrementalState, BackfillSourceFile, ParsedArgs, RunContext, SyncLocalLock, SyncLocalTriggerState, WritableLike } from './lib/types.js'
1010
import { spawn } from 'node:child_process'
11-
import { rm, stat } from 'node:fs/promises'
11+
import { realpath, rm, stat } from 'node:fs/promises'
1212
import os from 'node:os'
1313
import path from 'node:path'
1414
import { fileURLToPath } from 'node:url'
@@ -634,19 +634,19 @@ async function listBackfillSourceFiles(
634634
ctx: RunContext,
635635
): Promise<BackfillSourceFile[]> {
636636
if (source.id === 'opencode') {
637-
return opencodeBackfillFiles(stringOption(options['source-root']), resolveHome(options, ctx), ctx.env)
637+
return canonicalizeBackfillFiles(await opencodeBackfillFiles(stringOption(options['source-root']), resolveHome(options, ctx), ctx.env))
638638
}
639639
if (source.id === 'amp') {
640-
return ampBackfillFiles(stringOption(options['source-root']), resolveHome(options, ctx), ctx.env)
640+
return canonicalizeBackfillFiles(await ampBackfillFiles(stringOption(options['source-root']), resolveHome(options, ctx), ctx.env))
641641
}
642642
if (source.id === 'gemini') {
643-
return geminiBackfillFiles(stringOption(options['source-root']), resolveHome(options, ctx), ctx.env)
643+
return canonicalizeBackfillFiles(await geminiBackfillFiles(stringOption(options['source-root']), resolveHome(options, ctx), ctx.env))
644644
}
645645
if (source.id === 'codex') {
646646
const files = await codexBackfillFiles(stringOption(options['source-root']), resolveHome(options, ctx), ctx.env)
647-
return files
647+
return canonicalizeBackfillFiles(files
648648
.sort((a, b) => a.path.localeCompare(b.path))
649-
.slice(0, numberOption(options.limit) || undefined)
649+
.slice(0, numberOption(options.limit) || undefined))
650650
}
651651

652652
const roots = stringOption(options['source-root'])
@@ -658,10 +658,38 @@ async function listBackfillSourceFiles(
658658
.sort()
659659
.slice(0, numberOption(options.limit) || undefined)
660660

661-
return Promise.all(files.map(async (filePath) => {
661+
return canonicalizeBackfillFiles(await Promise.all(files.map(async (filePath) => {
662662
const info = await stat(filePath)
663663
return { path: filePath, modifiedAt: info.mtime.toISOString() }
664-
}))
664+
})))
665+
}
666+
667+
// Rollup identity includes a hash of the file's absolute path, so the same
668+
// physical file reached through different paths (symlinked agent homes like
669+
// CODEX_HOME=~/.codex-work → ~/.codex, macOS /var → /private/var) would
670+
// otherwise upload duplicate rollups — a hook-triggered sync and a manual one
671+
// can legitimately see different spellings of the same home. Resolve every
672+
// listed file to its physical path before parsing so all views agree on one
673+
// identity, and drop same-run duplicates that collapse together.
674+
async function canonicalizeBackfillFiles(files: BackfillSourceFile[]): Promise<BackfillSourceFile[]> {
675+
const seen = new Set<string>()
676+
const result: BackfillSourceFile[] = []
677+
for (const file of files) {
678+
let resolved = file.path
679+
try {
680+
resolved = await realpath(file.path)
681+
}
682+
catch {
683+
// Keep the literal path: a file that vanished between listing and now
684+
// (or an unreadable parent) still fails later with proper per-file logging.
685+
}
686+
if (seen.has(resolved)) {
687+
continue
688+
}
689+
seen.add(resolved)
690+
result.push(resolved === file.path ? file : { ...file, path: resolved })
691+
}
692+
return result
665693
}
666694

667695
function writeBackfillDiscover(plan: BackfillPlan, options: ParsedArgs, ctx: RunContext) {

packages/cli/src/lib/types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,15 @@ export interface BackfillSourceFile {
3535
// ccusage-parity token fixes — OpenCode step-finish double-count, Amp
3636
// tokens.total / ledger-less fallback, Codex cumulative-only token_count,
3737
// and the pi/gemini edge cases, and the v6 Codex UUIDv7 creation-anchor
38-
// that skips copied branch/goal rollout history). The CLI compares the
38+
// that skips copied branch/goal rollout history, and the v7 realpath
39+
// canonicalization of source file paths so symlinked agent homes stop
40+
// producing duplicate rollup identities). The CLI compares the
3941
// constant against the on-disk schema; on a mismatch it drops every
4042
// watermark so the next sync silently re-parses all jsonl from scratch
4143
// and upserts the rebuilt rollups (`replace: true` is already set) — no
4244
// purge, nothing deleted. Users get the fix transparently the next time
4345
// their agent runs.
44-
export const BACKFILL_STATE_SCHEMA_VERSION = 6
46+
export const BACKFILL_STATE_SCHEMA_VERSION = 7
4547

4648
export interface BackfillIncrementalState {
4749
version: typeof BACKFILL_STATE_SCHEMA_VERSION

packages/cli/test/cli.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { RunContext } from '../src/lib/types.ts'
22
import assert from 'node:assert/strict'
3-
import { mkdir, mkdtemp, readdir, readFile, stat, utimes, writeFile } from 'node:fs/promises'
3+
import { mkdir, mkdtemp, readdir, readFile, stat, symlink, utimes, writeFile } from 'node:fs/promises'
44
import { tmpdir } from 'node:os'
55
import path from 'node:path'
66
import { Readable } from 'node:stream'
@@ -447,6 +447,36 @@ test('codexBackfillFiles discovers archived_sessions and prefers the active copy
447447
assert.equal(files.has(path.join(archived, 'a.jsonl')), false) // deduped in favor of active
448448
})
449449

450+
test('symlinked Codex home yields the same rollup identity as the real path', async () => {
451+
const home = await createCodexBackfillHome()
452+
// Multi-account setups point CODEX_HOME at a shadow dir that symlinks the
453+
// real ~/.codex; a hook-triggered sync sees the shadow path while a manual
454+
// sync sees the real one. Both must resolve to one rollup identity.
455+
const shadowCodexHome = path.join(home, '.codex-shadow')
456+
await symlink(path.join(home, '.codex'), shadowCodexHome, 'dir')
457+
458+
const importOnce = async (env: Record<string, string>) => {
459+
let body = ''
460+
const exitCode = await run(['backfill', 'import', '--source', 'codex', '--home', home, '--api-url', 'http://example.test', '--force', '--json'], testContext({
461+
env: { HOME: home, ...env },
462+
fetch: async (_url, init) => {
463+
body = String(init?.body)
464+
const rollups = JSON.parse(body).rollups
465+
return Response.json({ inserted: rollups.length, skipped: 0, conflicts: 0, conflictIds: [] }, { status: 200 })
466+
},
467+
}))
468+
assert.equal(exitCode, 0)
469+
return JSON.parse(body).rollups
470+
}
471+
472+
const direct = await importOnce({})
473+
const viaSymlink = await importOnce({ CODEX_HOME: shadowCodexHome })
474+
475+
assert.equal(direct.length, 1)
476+
assert.equal(viaSymlink.length, 1)
477+
assert.equal(viaSymlink[0].rollupKey, direct[0].rollupKey)
478+
})
479+
450480
test('backfill import sends parsed Codex events and counts API results', async () => {
451481
const home = await createCodexBackfillHome()
452482
const calls: Array<{ url: string, body: string }> = []

0 commit comments

Comments
 (0)