Skip to content

Commit df17826

Browse files
committed
fix(export): bind reconciliation to snapshot order
Reporting snapshots must agree with daily usage on survivor selection and must not let pre-snapshot fallback state mask catalog rows visible to the snapshot. Binding timestamp comparison to SQLite text order and making fallback conditional on transaction-visible catalog emptiness preserves exact totals under mixed precision and concurrent catalog initialization.
1 parent f2c0d13 commit df17826

9 files changed

Lines changed: 190 additions & 22 deletions

File tree

cmd/agentsview/export_reporting.go

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -214,12 +214,7 @@ func openReportingExportDB(
214214
if err != nil {
215215
return nil, func() {}, err
216216
}
217-
if err := ensureExportSessionsPricing(
218-
cmd.Context(), database, appConfig,
219-
); err != nil {
220-
_ = database.Close()
221-
return nil, func() {}, err
222-
}
217+
applyEmptyCatalogPricing(database, appConfig.CustomModelPricing)
223218
return database, func() {
224219
_ = database.Close()
225220
}, nil

cmd/agentsview/export_reporting_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,87 @@ func TestExportReportingFallbackPricingOnUnseededArchive(t *testing.T) {
348348
assert.Positive(t, hour.Activity.Totals.Cost.Microdollars)
349349
}
350350

351+
func TestExportReportingSnapshotStoredPricingOverridesInstalledFallback(
352+
t *testing.T,
353+
) {
354+
dataDir := testDataDir(t)
355+
database := dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "sessions.db"))
356+
t.Cleanup(func() {
357+
require.NoError(t, database.Close())
358+
})
359+
model := exactFallbackPricedModel(t)
360+
require.NoError(t, database.UpsertSession(db.Session{
361+
ID: "fixture-snapshot-pricing",
362+
Machine: "fixture-machine",
363+
Agent: "agent snapshot",
364+
StartedAt: dbtest.Ptr("2026-07-28T10:00:00Z"),
365+
EndedAt: dbtest.Ptr("2026-07-28T10:06:00Z"),
366+
MessageCount: 2,
367+
UserMessageCount: 1,
368+
}))
369+
require.NoError(t, database.InsertMessages([]db.Message{
370+
{
371+
SessionID: "fixture-snapshot-pricing",
372+
Ordinal: 0,
373+
Role: "user",
374+
Content: "synthetic question",
375+
ContentLength: len("synthetic question"),
376+
Timestamp: "2026-07-28T10:00:00Z",
377+
},
378+
{
379+
SessionID: "fixture-snapshot-pricing",
380+
Ordinal: 1,
381+
Role: "assistant",
382+
Content: "synthetic answer",
383+
ContentLength: len("synthetic answer"),
384+
Timestamp: "2026-07-28T10:05:00Z",
385+
Model: model,
386+
TokenUsage: json.RawMessage(
387+
`{"input_tokens":1000,"output_tokens":500}`,
388+
),
389+
},
390+
}))
391+
392+
deps := defaultExportReportingDeps()
393+
deps.now = func() time.Time {
394+
return time.Date(2026, 7, 29, 14, 37, 0, 0, time.UTC)
395+
}
396+
openDatabase := deps.openDatabase
397+
deps.openDatabase = func(
398+
cmd *cobra.Command,
399+
) (*db.DB, func(), error) {
400+
reader, cleanup, err := openDatabase(cmd)
401+
if err != nil {
402+
return nil, func() {}, err
403+
}
404+
if err := database.UpsertModelPricing([]db.ModelPricing{{
405+
ModelPattern: model,
406+
InputPerMTok: money.MustParseDollars("123"),
407+
OutputPerMTok: money.MustParseDollars("456"),
408+
}}); err != nil {
409+
cleanup()
410+
return nil, func() {}, err
411+
}
412+
return reader, cleanup, nil
413+
}
414+
415+
stdout, stderr, err := executeExportSessionsCommand(
416+
newExportReportingTestRootWithDeps(deps),
417+
"export", "hour", "2026-07-28-10",
418+
)
419+
require.NoError(t, err)
420+
assert.Empty(t, stderr)
421+
422+
var hour export.ReportingHour
423+
require.NoError(t, json.Unmarshal([]byte(stdout), &hour))
424+
assert.Equal(
425+
t,
426+
money.Money{Microdollars: 351_000},
427+
hour.Usage.Totals.Cost,
428+
)
429+
assert.Equal(t, hour.Usage.Totals.Cost, hour.Activity.Totals.Cost)
430+
}
431+
351432
func newExportReportingTestRoot(now time.Time) *cobra.Command {
352433
deps := defaultExportReportingDeps()
353434
deps.now = func() time.Time { return now }

cmd/agentsview/usage.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,18 @@ func ensureUsagePricing(
420420
func applyFallbackPricing(
421421
database *db.DB, custom map[string]config.CustomModelRate,
422422
) {
423+
database.SetEffectivePricing(fallbackPricingRates(custom))
424+
}
425+
426+
func applyEmptyCatalogPricing(
427+
database *db.DB, custom map[string]config.CustomModelRate,
428+
) {
429+
database.SetEmptyCatalogPricing(fallbackPricingRates(custom))
430+
}
431+
432+
func fallbackPricingRates(
433+
custom map[string]config.CustomModelRate,
434+
) map[string]export.ModelRates {
423435
rates := make(map[string]export.ModelRates)
424436
for _, p := range pricing.FallbackPricing() {
425437
// These keys are the same concrete model-pattern keys that the
@@ -462,7 +474,7 @@ func applyFallbackPricing(
462474
Source: export.PricingRowSourceCustom,
463475
}
464476
}
465-
database.SetEffectivePricing(rates)
477+
return rates
466478
}
467479

468480
func fetchHTTPDailyUsage(

internal/activity/activity.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ type ActivityEvent struct {
5353
// UsageRow is one cost/token row from the usage-row union, with cost already
5454
// computed by the backend (so cost logic stays in each backend, matching
5555
// GetDailyUsage). Rows MUST be delivered ordered by
56-
// (ts ASC, session_id ASC, COALESCE(message_ordinal,-1) ASC).
56+
// (ts text in SQLite BINARY order, session_id ASC,
57+
// COALESCE(message_ordinal,-1) ASC).
5758
type UsageRow struct {
5859
SessionID string
5960
Model string

internal/db/activityreport.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,6 @@ func (db *DB) activityReportUsageFrom(
463463
type activityReportUsageCandidate struct {
464464
row activity.UsageRow
465465
scan dailyUsageScanRow
466-
ts time.Time
467466
ordinal int64
468467
}
469468

@@ -520,9 +519,7 @@ func (db *DB) loadActivityReportUsageCandidatesFrom(
520519
if r.messageOrdinal.Valid {
521520
ord = r.messageOrdinal.Int64
522521
}
523-
parsedTS, _ := parseTimestamp(r.ts)
524522
candidates = append(candidates, activityReportUsageCandidate{
525-
ts: parsedTS,
526523
ordinal: ord,
527524
scan: r,
528525
row: activity.UsageRow{
@@ -554,8 +551,10 @@ func sortActivityReportUsageCandidates(
554551
) {
555552
sort.SliceStable(candidates, func(i, j int) bool {
556553
a, b := candidates[i], candidates[j]
557-
if !a.ts.Equal(b.ts) {
558-
return a.ts.Before(b.ts)
554+
if compared := compareDailyUsageTimestampText(
555+
a.row.Timestamp, b.row.Timestamp,
556+
); compared != 0 {
557+
return compared < 0
559558
}
560559
if a.row.SessionID != b.row.SessionID {
561560
return a.row.SessionID < b.row.SessionID
@@ -567,6 +566,12 @@ func sortActivityReportUsageCandidates(
567566
})
568567
}
569568

569+
// compareDailyUsageTimestampText mirrors SQLite's default BINARY ordering for
570+
// the u.ts column used by GetDailyUsage's first-seen-wins survivor pass.
571+
func compareDailyUsageTimestampText(a, b string) int {
572+
return cmp.Compare(a, b)
573+
}
574+
570575
// activityReportUsageCandidatesFrom returns normalized padded-range rows
571576
// without sorting or applying a survivor mask. Reporting export merges these
572577
// rows with standalone candidates before imposing either operation.

internal/db/db.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -546,8 +546,9 @@ type DB struct {
546546
cursorMu sync.RWMutex
547547
cursorSecret []byte
548548

549-
customPricing map[string]config.CustomModelRate
550-
effectivePricing map[string]export.ModelRates
549+
customPricing map[string]config.CustomModelRate
550+
effectivePricing map[string]export.ModelRates
551+
emptyCatalogPricing map[string]export.ModelRates
551552

552553
checkpointMu sync.Mutex
553554
checkpointStop chan struct{}
@@ -808,6 +809,18 @@ func (db *DB) SetEffectivePricing(
808809
}
809810
}
810811

812+
// SetEmptyCatalogPricing installs in-memory rates that are used only when the
813+
// query source loading pricing sees no stored catalog rows.
814+
func (db *DB) SetEmptyCatalogPricing(
815+
p map[string]export.ModelRates,
816+
) {
817+
db.emptyCatalogPricing = make(map[string]export.ModelRates, len(p))
818+
for model, rates := range p {
819+
rates.Bands = append([]export.PricingBand(nil), rates.Bands...)
820+
db.emptyCatalogPricing[model] = rates
821+
}
822+
}
823+
811824
// SetCursorSecret updates the secret key used for cursor signing.
812825
func (db *DB) SetCursorSecret(secret []byte) {
813826
db.cursorMu.Lock()

internal/db/reporting_export.go

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -377,13 +377,10 @@ func (db *DB) reportingStandaloneUsageCandidatesFrom(
377377
func sortReportingUsage(rows []activity.UsageRow) {
378378
sort.SliceStable(rows, func(i, j int) bool {
379379
a, b := rows[i], rows[j]
380-
aTime, aErr := parseTimestamp(a.Timestamp)
381-
bTime, bErr := parseTimestamp(b.Timestamp)
382-
if aErr == nil && bErr == nil && !aTime.Equal(bTime) {
383-
return aTime.Before(bTime)
384-
}
385-
if a.Timestamp != b.Timestamp {
386-
return a.Timestamp < b.Timestamp
380+
if compared := compareDailyUsageTimestampText(
381+
a.Timestamp, b.Timestamp,
382+
); compared != 0 {
383+
return compared < 0
387384
}
388385
if a.SessionID != b.SessionID {
389386
return a.SessionID < b.SessionID

internal/db/reporting_export_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,64 @@ func TestReportingExportDeduplicatesMergedUsageInputs(t *testing.T) {
649649
assert.Zero(t, hour.Activity.Totals.NewModels)
650650
}
651651

652+
func TestReportingExportDedupMatchesDailyUsageForMixedTimestampPrecision(
653+
t *testing.T,
654+
) {
655+
d := testDB(t)
656+
insertSession(t, d, "fixture-mixed-precision", "project precision", func(s *Session) {
657+
s.Agent = "agent precision"
658+
s.StartedAt = Ptr("2026-07-28T09:00:00Z")
659+
s.EndedAt = Ptr("2026-07-28T09:01:00Z")
660+
})
661+
sessionCost := money.MustParseDollars("0.002")
662+
require.NoError(t, d.ReplaceSessionUsageEvents(
663+
"fixture-mixed-precision",
664+
[]UsageEvent{{
665+
Source: "fixture-source",
666+
Model: "model earlier instant",
667+
InputTokens: 41,
668+
OutputTokens: 7,
669+
Cost: &sessionCost,
670+
CostStatus: "exact",
671+
CostSource: "reported",
672+
OccurredAt: "2026-07-28T09:00:00Z",
673+
DedupKey: "shared",
674+
}},
675+
))
676+
require.NoError(t, d.InsertCursorUsageEvents([]CursorUsageEvent{{
677+
OccurredAt: "2026-07-28T09:00:00.123Z",
678+
Model: "model text-order winner",
679+
Kind: "usage",
680+
InputTokens: 17,
681+
OutputTokens: 3,
682+
Charged: money.MustParseDollars("0.007"),
683+
DedupKey: "fixture-mixed-precision:fixture-source:shared",
684+
}}))
685+
686+
day, err := d.ExportReportingDay(context.Background(), ReportingExportOptions{
687+
Date: time.Date(2026, 7, 28, 0, 0, 0, 0, time.UTC),
688+
Now: time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC),
689+
})
690+
require.NoError(t, err)
691+
daily, err := d.GetDailyUsage(context.Background(), UsageFilter{
692+
From: "2026-07-28",
693+
To: "2026-07-28",
694+
Timezone: "UTC",
695+
Breakdowns: true,
696+
})
697+
require.NoError(t, err)
698+
699+
hour := day.Hours[9]
700+
assert.Equal(t, int64(17), hour.Usage.Totals.InputTokens)
701+
assert.Equal(t, int64(3), hour.Usage.Totals.OutputTokens)
702+
assert.Equal(t, money.MustParseDollars("0.007"), hour.Usage.Totals.Cost)
703+
require.Len(t, hour.Usage.ByModel, 1)
704+
assert.Equal(t, "model text-order winner", hour.Usage.ByModel[0].Key)
705+
assert.Equal(t, daily.Totals.InputTokens, int(hour.Usage.Totals.InputTokens))
706+
assert.Equal(t, daily.Totals.OutputTokens, int(hour.Usage.Totals.OutputTokens))
707+
assert.Equal(t, daily.Totals.TotalCost, hour.Usage.Totals.Cost)
708+
}
709+
652710
func TestReportingExportPreservesMessageOrdinalForDedup(t *testing.T) {
653711
d := testDB(t)
654712
insertSession(t, d, "fixture-ordinal", "", func(s *Session) {

internal/db/usage.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1808,6 +1808,12 @@ func (db *DB) loadPricingMapFrom(
18081808
out[p.ModelPattern] = rates
18091809
}
18101810

1811+
if len(out) == 0 {
1812+
for model, rates := range db.emptyCatalogPricing {
1813+
rates.Bands = append([]export.PricingBand(nil), rates.Bands...)
1814+
out[model] = rates
1815+
}
1816+
}
18111817
for model, cp := range db.customPricing {
18121818
rates := export.ModelRates{
18131819
InputPerMTok: money.Money{

0 commit comments

Comments
 (0)