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
27 changes: 24 additions & 3 deletions packages/cli/src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,28 @@ import { homedir } from 'os'
import { join } from 'path'
import type { DateRange, ProjectSummary } from './types.js'

// Bumped to 15: per-project daily rollups. Days and provider slices now carry
// Bumped to 17: dedup-key hygiene (#931, this PR). The codebuff, zerostack,
// pi/omp and grok decoders now thread a FINGERPRINT of the source path into
// their dedup keys (and lingtai-tui normalizes the model component) instead of
// the raw path / raw ledger text, because dedupKey ships on the observation
// envelope. Unchanged files are served from the session cache, whose dedup
// sets are seeded from the CACHED keys — so a warm cache built by the pre-fix
// binary keeps the raw-path keys, the same records re-ingest under the new
// key shape, totals go inconsistent, and the raw path stays on disk forever.
// The per-provider parse versions (session-cache.ts PROVIDER_PARSE_VERSIONS)
// force the session-cache re-parse that drops those keys; this bump forces the
// daily cache to re-derive every day whose sources survive instead of serving
// the pre-fix rollups. Raising MIN_SUPPORTED_VERSION to 17 makes a v16 file
// load as an old-version file rather than the trusted current cache.
//
// v16 is SKIPPED: main already spent it on the codex structural-discovery fix
// (eece4cf, #873/#626). A user who has ever run a main build owns a v16 cache
// containing only the codex fix; claiming 16 here too would load that file as
// CURRENT and COMPLETE, so the invalidation would never fire — the exact
// failure this bump exists to prevent. Claiming 17 instead sends that v16 file
// through the old-version adoption/re-derive path.
//
// v15: per-project daily rollups. Days and provider slices now carry
// a `projects` breakdown (cost/calls/savings/sessions per project) so project
// history outlives the session files, like models and categories already do.
// This bump is the first to ride the v14 carry-forward: the old cache is
Expand Down Expand Up @@ -57,8 +78,8 @@ import type { DateRange, ProjectSummary } from './types.js'
// that older binaries skipped. v8 added local-model savings to the daily
// rollup; the `savingsConfigHash` field is invalidated separately when the
// user changes their `localModelSavings` mapping.
export const DAILY_CACHE_VERSION = 15
const MIN_SUPPORTED_VERSION = 15
export const DAILY_CACHE_VERSION = 17
const MIN_SUPPORTED_VERSION = 17
// Version-suffixed so different binaries each own a distinct file and never
// clobber an incompatible schema. Bumping the version mints a fresh filename;
// adoptOlderDailyCaches then unions days out of every previous file (including
Expand Down
21 changes: 17 additions & 4 deletions packages/cli/src/providers/bridge.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { DecodeContext } from '@codeburn/core'

import { getHostPrivacyKey } from '../privacy-key.js'
import type { Provider, ProbeRoot, SessionSource, SessionParser, ParsedProviderCall } from './types.js'

// ── The dual-registry bridge ────────────────────────────────────────────────
Expand Down Expand Up @@ -81,10 +82,22 @@ export function createBridgedProvider<TRich>(spec: BridgedProviderSpec<TRich>):
async *parse(): AsyncGenerator<ParsedProviderCall> {
const records = await spec.readRecords(source)
if (records === null) return
// The CLI holds the rich decode only; minimization / fingerprinting
// happens on the sync path, so an empty privacy key is correct here
// (the rich decoder never consumes it), matching claude/codex.
const context: DecodeContext = { privacyKey: '', providerId: spec.name, sourceRef: source.path }
// The host privacy key, threaded into the rich decode (D1). An empty
// key was correct when the bridge was written — the comment then said
// the rich decoder never consumes it, because minimization /
// fingerprinting happened later on the sync path. That intent is
// OVERTAKEN by the sourceRef-fingerprint work (#931): the rich
// decoders now derive their dedup keys from sourceRefFingerprint,
// and dedupKey ships on the observation envelope, so the rich decode
// DOES consume the key. On an empty key core's fingerprint module
// throws (it never degrades to an unkeyed digest), so the bridge has
// to supply the real one. getHostPrivacyKey() is per-install stable
// (persisted, like the optimize detectors use), so dedup keys stay
// stable across runs and the session-cache re-parse / dedup
// semantics are unchanged; it only falls back to a per-process key
// when the config dir is unwritable, in which case the session cache
// cannot persist either.
const context: DecodeContext = { privacyKey: getHostPrivacyKey(), providerId: spec.name, sourceRef: source.path }
const { calls } = spec.decode({ records, context, seenKeys })
for (const rich of calls) {
yield spec.toProviderCall(rich)
Expand Down
18 changes: 16 additions & 2 deletions packages/cli/src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,13 @@ export const DURABLE_PROVIDER_NAMES: ReadonlySet<string> = new Set(['copilot'])
// needs no suffix: the cli-shutdown-cost-v1 bump below already forces its one
// re-parse, which lands the flag too, and durable orphans now survive
// fingerprint changes (the carry-forward in getOrCreateProviderSection).
// Dedup-key hygiene (#931): codebuff, zerostack, pi/omp and grok now thread a
// FINGERPRINT of the source path into their dedup keys (and lingtai-tui
// normalizes the model component) instead of the raw path / raw ledger text.
// The session cache seeds its dedup sets from the CACHED keys, so a pre-fix
// cache keeps the raw-path keys and the same records re-ingest under the new
// key shape. Each entry/suffix below changes the provider's env fingerprint,
// which forces the one-time re-parse that drops the raw-path keys from disk.
export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// rich-session-capture-v1: parse-time capture of per-turn gitBranch, per-call
// LOC deltas / interruptions / userModified / toolErrors, and session-level
Expand All @@ -217,9 +224,16 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
cursor: 'composer-anchored-crediting-v1-est-cost',
'cursor-agent': 'workspaceless-transcript-v1',
copilot: 'cli-shutdown-cost-v1-skills',
grok: 'estimated-cost-v1',
// source-ref-fingerprint-v1: the dedup key now threads a fingerprint of the
// source path (chat dir) instead of the raw path, which ships on the
// envelope. Forces one re-parse so cached raw-path keys are dropped.
codebuff: 'source-ref-fingerprint-v1',
zerostack: 'source-ref-fingerprint-v1',
pi: 'source-ref-fingerprint-v1',
omp: 'source-ref-fingerprint-v1',
grok: 'estimated-cost-v1-source-ref-fingerprint-v1',
hermes: 'reasoning-output-accounting-v1-est-cost',
'lingtai-tui': 'token-ledger-registry-activity-v3',
'lingtai-tui': 'token-ledger-registry-activity-v3-source-ref-fp-v1-model-normalized-v1',
'ibm-bob': 'worktree-project-grouping-v1',
kiro: 'ide-parsing-v1-est-cost',
quickdesk: 'emf-sqlite-v2-est-cost',
Expand Down
44 changes: 43 additions & 1 deletion packages/cli/tests/daily-cache-carry-forward.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, readFile, rename, rm, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
Expand Down Expand Up @@ -304,6 +304,48 @@ describe('never-lose invariant: invalidations with vanished sources', () => {
expect(out.days[0]).toMatchObject({ date: d.date, cost: d.cost, calls: d.calls, carried: true })
})

it('a version bump forces a re-derive that drops the raw-path dedup keys', async () => {
// The pre-fix binary shipped daily-cache v15. THIS LITERAL IS THE POINT:
// it must stay pinned to the version the pre-fix binary wrote, so a warm
// complete cache from that binary sits at daily-cache.v15.json with dedup
// keys threaded on RAW source paths (codebuff/zerostack/pi/omp/grok) and
// raw ledger model text (lingtai-tui). Those keys ship on the observation
// envelope, and the session cache seeds its dedup sets from the cached
// keys, so the pre-fix cache re-ingests the same records under the new
// key shapes — the pre-fix day below carries the inflated double count.
// Only the MIN_SUPPORTED_VERSION bump decides whether that file loads as
// the trusted CURRENT cache — freezing the inflated rollup forever — or as
// an old-version file that forces the one-time re-derive, which drops the
// raw-path keys and lands the corrected single count. When the next bump
// lands, move this literal to the version the current binary shipped.
const PRE_FIX_CACHE_VERSION = 15
const preFixDay = day(daysAgoStr(30), { codebuff: slice(10.0, 2) })
const preFixCache: DailyCache = {
version: PRE_FIX_CACHE_VERSION,
savingsConfigHash: 'cfg-A',
tzKey: currentTzKey(),
lastComputedDate: daysAgoStr(1),
days: [preFixDay],
complete: true,
}
await writeFile(join(TMP_CACHE_ROOT, `daily-cache.v${PRE_FIX_CACHE_VERSION}.json`), JSON.stringify(preFixCache), 'utf-8')

// The re-derive under the fingerprint-shaped keys sees ONE record per
// session (the raw-path keys no longer collide with the new keys, so the
// re-ingestion is gone): corrected 5.0 / 1 call.
const aggregate = vi.fn(() => [day(daysAgoStr(30), { codebuff: slice(5.0, 1) })])
const out = await ensureCacheHydrated(noSessions, aggregate, 'cfg-A')

// The bump forced a full re-derivation: the fresh parse was consulted.
expect(aggregate).toHaveBeenCalled()
expect(out.version).toBe(DAILY_CACHE_VERSION)
expect(out.complete).toBe(true)
// The corrected single count wins; the inflated pre-fix slice is gone.
expect(out.days[0]!.providers['codebuff']!.cost).toBe(5.0)
expect(out.days[0]!.providers['codebuff']!.calls).toBe(1)
expect(out.days[0]!.cost).toBe(5.0)
})

it('a same-version file found under an old name is trusted as-is (no spurious rebuild)', async () => {
const d = await seed()
await rename(dailyCachePath(), join(TMP_CACHE_ROOT, 'daily-cache.json'))
Expand Down
20 changes: 15 additions & 5 deletions packages/cli/tests/providers/codebuff-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,29 @@ import { describe, it, expect } from 'vitest'

import { createCodebuffProvider } from '../../src/providers/codebuff.js'
import { priceProviderCall } from '../../src/pricing-pass.js'
import { getHostPrivacyKey } from '../../src/privacy-key.js'
import { sourceRefFingerprint } from '@codeburn/core'
import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js'

// Byte-identical parity gate for the codebuff bridge migration. The GOLDEN below
// was captured from the legacy in-CLI decode before the migration; the bridged
// provider (discovery + I/O CLI-side, pure decode in @codeburn/core/providers/codebuff)
// must reproduce it exactly. Dedup keys contain absolute source paths, so they are
// computed from the discovered source at runtime rather than hard-coded.
// must reproduce it exactly. Dedup keys thread a FINGERPRINT of the source path
// (dedupKey ships on the envelope, so the raw path must never appear there), so
// the expected values are DERIVED from the source at runtime via the same
// sourceRefFingerprint the decoder uses — never the raw path, and never a
// hard-coded literal. The bridge threads the HOST privacy key (getHostPrivacyKey,
// per-install stable), so the golden derives with the same key.

const here = dirname(fileURLToPath(import.meta.url))
const FIXTURE_DIR = resolve(here, '../fixtures/codebuff-parity/manicode')

function expectedGolden(sourcePath: string): ParsedProviderCall[] {
const chatDir = sourcePath
// The CLI bridge threads the host privacy key into the rich decode, so the
// decoder keys the source ref under getHostPrivacyKey() — derive the
// expected key the same way instead of pasting what the code emits.
const chatRef = sourceRefFingerprint(getHostPrivacyKey(), chatDir)
return [
{
provider: 'codebuff',
Expand All @@ -35,7 +45,7 @@ function expectedGolden(sourcePath: string): ParsedProviderCall[] {
bashCommands: ['npm', 'npm'],
timestamp: '2026-04-14T10:00:30.000Z',
speed: 'standard',
deduplicationKey: `codebuff:${chatDir}:a1`,
deduplicationKey: `codebuff:${chatRef}:a1`,
userMessage: 'implement the feature',
sessionId: 'manicode/2026-04-14T10-00-00.000Z',
},
Expand All @@ -55,7 +65,7 @@ function expectedGolden(sourcePath: string): ParsedProviderCall[] {
bashCommands: [],
timestamp: '2026-04-14T10:01:30.000Z',
speed: 'standard',
deduplicationKey: `codebuff:${chatDir}:a2`,
deduplicationKey: `codebuff:${chatRef}:a2`,
userMessage: 'fix the bug',
sessionId: 'manicode/2026-04-14T10-00-00.000Z',
},
Expand All @@ -75,7 +85,7 @@ function expectedGolden(sourcePath: string): ParsedProviderCall[] {
bashCommands: [],
timestamp: '2026-04-14T10:02:00.000Z',
speed: 'standard',
deduplicationKey: `codebuff:${chatDir}:a3`,
deduplicationKey: `codebuff:${chatRef}:a3`,
userMessage: '',
sessionId: 'manicode/2026-04-14T10-00-00.000Z',
},
Expand Down
16 changes: 13 additions & 3 deletions packages/cli/tests/providers/grok-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,22 @@ import { describe, it, expect } from 'vitest'

import { createGrokProvider } from '../../src/providers/grok.js'
import { priceProviderCall } from '../../src/pricing-pass.js'
import { getHostPrivacyKey } from '../../src/privacy-key.js'
import { sourceRefFingerprint } from '@codeburn/core'
import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js'

// Byte-identical parity gate for the grok bridge migration (phase 8). The
// GOLDEN below was captured from the legacy in-CLI decode before the migration.

const here = dirname(fileURLToPath(import.meta.url))
const FIXTURE_DIR = resolve(here, '../fixtures/grok-parity')
// The dedup key threads a FINGERPRINT of the session dir — the raw path is the
// defect and must never appear (dedupKey ships on the envelope), so the
// expected value is DERIVED via the same sourceRefFingerprint the decoder
// uses. The bridge threads the HOST privacy key (getHostPrivacyKey, per-install
// stable), so the golden derives with the same key.
const SESSION_DIR = resolve(FIXTURE_DIR, '%2FUsers%2Ftest/019edf9c-0000-7000-8000-000000000001')
const SESSION_REF = sourceRefFingerprint(getHostPrivacyKey(), SESSION_DIR)

const GOLDEN: ParsedProviderCall[] = [
{
Expand All @@ -33,9 +42,10 @@ const GOLDEN: ParsedProviderCall[] = [
subagentTypes: ['general-purpose'],
timestamp: '2026-06-19T11:31:12.282793Z',
speed: 'standard',
// The key embeds the session dir's absolute path — compute it from
// FIXTURE_DIR so the golden is portable across checkouts.
deduplicationKey: `grok:${resolve(FIXTURE_DIR, '%2FUsers%2Ftest/019edf9c-0000-7000-8000-000000000001')}:2026-06-19T11:31:12.282793Z:019edf9c-0000-7000-8000-000000000001`,
// The key embeds a fingerprint of the session dir's absolute path —
// derived from FIXTURE_DIR so the golden is portable across checkouts, and
// never the raw path itself.
deduplicationKey: `grok:${SESSION_REF}:2026-06-19T11:31:12.282793Z:019edf9c-0000-7000-8000-000000000001`,
userMessage: 'User asks about the repo',
sessionId: '019edf9c-0000-7000-8000-000000000001',
project: 'myproject',
Expand Down
14 changes: 11 additions & 3 deletions packages/cli/tests/providers/lingtai-tui-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { describe, it, expect } from 'vitest'

import { createLingTaiTuiProvider } from '../../src/providers/lingtai-tui.js'
import { priceProviderCall } from '../../src/pricing-pass.js'
import { getHostPrivacyKey } from '../../src/privacy-key.js'
import { sourceRefFingerprint } from '@codeburn/core'
import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js'

// Byte-identical parity gate for the lingtai-tui bridge migration (phase 8).
Expand All @@ -14,8 +16,10 @@ import type { ParsedProviderCall, SessionSource } from '../../src/providers/type
// per-source-label activity synthesis (main / tc_wake / daemon => userMessage +
// tools + subagentTypes), model/endpoint fallback from the manifest when a
// ledger row omits them, run_id vs `${agentId}:${label}` session ids, the
// composite dedup key threaded on the SOURCE PATH (not the agent dir), turnId,
// and the manifest-derived project / projectPath carried onto the call.
// composite dedup key threaded on a FINGERPRINT of the source path (never the
// raw path — dedupKey ships on the envelope; the raw ledger model is normalized
// in the key), turnId, and the manifest-derived project / projectPath carried
// onto the call.
const here = dirname(fileURLToPath(import.meta.url))
const FIXTURE_DIR = resolve(here, '../fixtures/lingtai-parity')

Expand All @@ -33,7 +37,11 @@ function dedup(
thinking: number,
cached: number,
): string {
return ['lingtai-tui', sourcePath, lineNo, ts, model, endpoint, label, emId, runId, input, output, thinking, cached].join(':')
// The bridge threads the HOST privacy key into the rich decode
// (getHostPrivacyKey, per-install stable), so the decoder keys the source ref
// under that key — derive the expected key the same way instead of pasting
// what the code emits.
return ['lingtai-tui', sourceRefFingerprint(getHostPrivacyKey(), sourcePath), lineNo, ts, model, endpoint, label, emId, runId, input, output, thinking, cached].join(':')
}

function golden(sourcePath: string, agentDir: string): ParsedProviderCall[] {
Expand Down
Loading
Loading