Skip to content

Commit f95cc17

Browse files
committed
fix(export): align reporting usage reconciliation
First-seen usage deduplication must share daily usage's primary ordering or equal-time duplicates can select different token, cost, model, and project facts. Carrying source and ordinal through the merged stream preserves that reconciliation while retaining deterministic semantic tie-breakers.\n\nRead-only reporting also needs the established empty-catalog fallback so fresh archives price known models without mutating storage or replacing a populated catalog.
1 parent 8dd34c8 commit f95cc17

9 files changed

Lines changed: 507 additions & 198 deletions

cmd/agentsview/export_reporting.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,12 @@ 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+
}
217223
return database, func() {
218224
_ = database.Close()
219225
}, nil

cmd/agentsview/export_reporting_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,61 @@ func TestExportReportingRunsWhileWriteOwnerLockHeld(t *testing.T) {
293293
assert.Empty(t, stderr)
294294
}
295295

296+
func TestExportReportingFallbackPricingOnUnseededArchive(t *testing.T) {
297+
dataDir := testDataDir(t)
298+
database := dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "sessions.db"))
299+
model := exactFallbackPricedModel(t)
300+
require.NoError(t, database.UpsertSession(db.Session{
301+
ID: "fixture-fallback-priced",
302+
Machine: "fixture-machine",
303+
Agent: "agent fallback",
304+
StartedAt: dbtest.Ptr("2026-07-28T10:00:00Z"),
305+
EndedAt: dbtest.Ptr("2026-07-28T10:06:00Z"),
306+
MessageCount: 2,
307+
UserMessageCount: 1,
308+
}))
309+
require.NoError(t, database.InsertMessages([]db.Message{
310+
{
311+
SessionID: "fixture-fallback-priced",
312+
Ordinal: 0,
313+
Role: "user",
314+
Content: "synthetic question",
315+
ContentLength: len("synthetic question"),
316+
Timestamp: "2026-07-28T10:00:00Z",
317+
},
318+
{
319+
SessionID: "fixture-fallback-priced",
320+
Ordinal: 1,
321+
Role: "assistant",
322+
Content: "synthetic answer",
323+
ContentLength: len("synthetic answer"),
324+
Timestamp: "2026-07-28T10:05:00Z",
325+
Model: model,
326+
TokenUsage: json.RawMessage(
327+
`{"input_tokens":1000,"output_tokens":500}`,
328+
),
329+
},
330+
}))
331+
seeded, err := database.HasModelPricingRows(context.Background())
332+
require.NoError(t, err)
333+
assert.False(t, seeded)
334+
require.NoError(t, database.Close())
335+
336+
stdout, stderr, err := executeExportSessionsCommand(
337+
newExportReportingTestRoot(
338+
time.Date(2026, 7, 29, 14, 37, 0, 0, time.UTC),
339+
),
340+
"export", "hour", "2026-07-28-10",
341+
)
342+
require.NoError(t, err)
343+
assert.Empty(t, stderr)
344+
345+
var hour export.ReportingHour
346+
require.NoError(t, json.Unmarshal([]byte(stdout), &hour))
347+
assert.Positive(t, hour.Usage.Totals.Cost.Microdollars)
348+
assert.Positive(t, hour.Activity.Totals.Cost.Microdollars)
349+
}
350+
296351
func newExportReportingTestRoot(now time.Time) *cobra.Command {
297352
deps := defaultExportReportingDeps()
298353
deps.now = func() time.Time { return now }

docs/reporting-export.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@ archive snapshot even when a sync writes concurrently. Usage deduplication and
125125
authoritative session-cost allocation happen once on the merged usage stream
126126
across the day before rows are partitioned by hour.
127127

128+
First-seen usage deduplication orders candidates by occurrence time, session ID
129+
ascending, and `COALESCE(message_ordinal, -1)` ascending, matching the Usage
130+
view. A standalone row retains its empty session ID and therefore sorts before a
131+
session-linked row at the same time. Source and the remaining semantic fields
132+
provide deterministic tie-breakers only after that shared ordering prefix.
133+
128134
Arrays use stable contract ordering. Canonical JSON preserves declared JSON
129135
field names and encodes money exactly. Version 1 uses a project-specific
130136
canonical format and does not claim RFC 8785 or JSON Canonicalization Scheme
@@ -220,6 +226,10 @@ was current when the hour closed, so a later catalog change can change cost and
220226
therefore the hour digest without changing token facts. Integrations should
221227
treat such a digest change as an ordinary source correction.
222228

229+
When the archive has no stored model-pricing rows, the read-only reporting
230+
command applies the embedded fallback catalog and configured custom prices in
231+
memory. It does not write to the archive or replace a non-empty stored catalog.
232+
223233
The sum of a completed export day's usage fields reconciles with the Usage view
224234
for the same UTC date and filters. Usage-session selection follows usage
225235
timestamps independently of the session activity window.

docs/superpowers/plans/2026-07-30-reporting-export-contract-corrections.md

Lines changed: 244 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ and validate the CLI schema version before opening storage.
3333
components after validation.
3434
- Use only conspicuously synthetic names, labels, paths, repositories, and
3535
domains in tests, fixtures, documentation, and commit text.
36-
- Do not push, rewrite history, or interact with the pull request.
36+
- Push the new follow-up commit normally after verification. Do not rewrite
37+
history or post pull-request comments.
3738

3839
______________________________________________________________________
3940

@@ -637,5 +638,245 @@ docs(export): freeze completed reporting schema v1
637638

638639
- [ ] **Step 8: Confirm the final local state**
639640

640-
Run `git status --short` and report the commits and verification results. Do not
641-
push, rewrite history, or post pull-request comments.
641+
Run `git status --short` and record the commits and verification results for
642+
that checkpoint. Task 6 performs the final push without rewriting history or
643+
posting a pull-request comment.
644+
645+
### Task 5: Match daily-usage survivor ordering
646+
647+
**Files:**
648+
649+
- Modify: `internal/activity/activity.go`
650+
- Modify: `internal/db/activityreport.go`
651+
- Modify: `internal/db/reporting_export.go`
652+
- Test: `internal/db/reporting_export_test.go`
653+
654+
**Interfaces:**
655+
656+
- Extends `activity.UsageRow` with `UsageSource string` and
657+
`MessageOrdinal int64`, where `-1` represents a missing message ordinal.
658+
659+
- Preserves
660+
`finalizeReportingUsage(activity.Query, []activity.UsageRow) []activity.UsageRow`.
661+
662+
- Preserves the current semantic comparators as deterministic tie-breakers after
663+
timestamp, session ID, and ordinal.
664+
665+
- [x] **Step 1: Write regressions for primary and trailing ordering**
666+
667+
Add a table-driven `TestFinalizeReportingUsageOrdering` that constructs real
668+
`activity.UsageRow` values with one shared `UsageDedupKey` and literal token and
669+
cost values. Cover these cases:
670+
671+
```go
672+
{
673+
name: "empty session sorts before linked session",
674+
rows: []activity.UsageRow{
675+
{
676+
SessionID: "fixture-session", MessageOrdinal: -1,
677+
UsageSource: "usage_event", UsageDedupKey: "shared",
678+
Timestamp: "2026-07-28T09:05:00Z", InputTokens: 41,
679+
},
680+
{
681+
SessionID: "", MessageOrdinal: -1,
682+
UsageSource: "cursor", UsageDedupKey: "shared",
683+
Timestamp: "2026-07-28T09:05:00Z", InputTokens: 17,
684+
},
685+
},
686+
wantInput: 17,
687+
},
688+
{
689+
name: "lower ordinal wins before semantic fields",
690+
rows: []activity.UsageRow{
691+
{
692+
SessionID: "fixture-session", MessageOrdinal: 2,
693+
UsageSource: "message", UsageDedupKey: "shared",
694+
Timestamp: "2026-07-28T09:05:00Z", InputTokens: 11,
695+
},
696+
{
697+
SessionID: "fixture-session", MessageOrdinal: 1,
698+
UsageSource: "usage_event", UsageDedupKey: "shared",
699+
Timestamp: "2026-07-28T09:05:00Z", InputTokens: 29,
700+
},
701+
},
702+
wantInput: 29,
703+
},
704+
```
705+
706+
Add a third case with equal timestamp, session, and ordinal but different
707+
sources and semantic values. Assert exact output for both insertion orders so
708+
the trailing source and semantic comparators remain deterministic.
709+
710+
Extend `TestReportingExportDeduplicatesMergedUsageInputs` so the standalone and
711+
session-linked rows have the same occurrence time and stable dedup token but
712+
distinct input tokens, costs, models, and project attribution. Compare the
713+
exported day with `GetDailyUsage` and assert the literal winning token and cost
714+
values.
715+
716+
- [x] **Step 2: Run the ordering regressions and verify RED**
717+
718+
Run:
719+
720+
```bash
721+
CGO_ENABLED=1 go test -tags fts5 ./internal/db \
722+
-run 'TestFinalizeReportingUsageOrdering|TestReportingExportDeduplicatesMergedUsageInputs' \
723+
-count=1
724+
```
725+
726+
Expected: FAIL because reporting puts the non-empty session ID first and has no
727+
ordinal or source metadata after candidate materialization.
728+
729+
- [x] **Step 3: Carry normalized ordering metadata**
730+
731+
Add these internal fields to `activity.UsageRow`:
732+
733+
```go
734+
UsageSource string
735+
MessageOrdinal int64
736+
```
737+
738+
When `loadActivityReportUsageCandidatesFrom` maps a scanned row, copy
739+
`r.usageSource` and the normalized ordinal already computed for sorting.
740+
Standalone Cursor candidates set `UsageSource: "cursor"` and
741+
`MessageOrdinal: -1`. Do not fabricate session or project values.
742+
743+
- [x] **Step 4: Apply the daily-usage ordering prefix**
744+
745+
Update `sortReportingUsage` to compare:
746+
747+
1. parsed occurrence instant and raw timestamp fallback;
748+
1. `SessionID` using ordinary ascending string order, including empty string;
749+
1. `MessageOrdinal` ascending; and
750+
1. `UsageSource` followed by the existing deterministic semantic comparators.
751+
752+
Remove the special non-empty-session preference. Keep every existing semantic
753+
comparison after the primary prefix.
754+
755+
- [x] **Step 5: Run focused tests and verify GREEN**
756+
757+
Run:
758+
759+
```bash
760+
CGO_ENABLED=1 go test -tags fts5 ./internal/db \
761+
-run 'TestFinalizeReportingUsageOrdering|TestReportingExportDeduplicatesMergedUsageInputs|TestReportingExportIncludesStandaloneRowsOnlyInUsage|TestReportingExportIsIndependentOfArchiveLayout' \
762+
-count=1
763+
```
764+
765+
Expected: PASS.
766+
767+
### Task 6: Prepare reporting fallback pricing
768+
769+
**Files:**
770+
771+
- Modify: `cmd/agentsview/export_reporting.go`
772+
- Test: `cmd/agentsview/export_reporting_test.go`
773+
- Delete:
774+
`docs/superpowers/specs/2026-07-30-reporting-export-contract-corrections-design.md`
775+
776+
**Interfaces:**
777+
778+
- Reuses
779+
`ensureExportSessionsPricing(context.Context, *db.DB, config.Config) error`.
780+
781+
- Preserves `openReportingExportDB(*cobra.Command) (*db.DB, func(), error)`.
782+
783+
- [x] **Step 1: Write the unseeded-archive regression**
784+
785+
Create `TestExportReportingFallbackPricingOnUnseededArchive`. Open a real
786+
temporary archive, leave `model_pricing` empty, and seed a synthetic session
787+
with a known exact embedded fallback model:
788+
789+
```go
790+
model := exactFallbackPricedModel(t)
791+
```
792+
793+
Insert a user message at `2026-07-28T10:00:00Z` and an assistant message with
794+
literal usage at `2026-07-28T10:05:00Z`:
795+
796+
```json
797+
{"input_tokens":1000,"output_tokens":500}
798+
```
799+
800+
Run `export hour 2026-07-28-10`, decode the reporting hour, and assert:
801+
802+
```go
803+
assert.Positive(t, hour.Usage.Totals.Cost.Microdollars)
804+
assert.Positive(t, hour.Activity.Totals.Cost.Microdollars)
805+
```
806+
807+
The observable nonzero totals catch removal of the fallback setup without
808+
mirroring the pricing implementation.
809+
810+
- [x] **Step 2: Run the fallback regression and verify RED**
811+
812+
Run:
813+
814+
```bash
815+
CGO_ENABLED=1 go test -tags fts5 ./cmd/agentsview \
816+
-run TestExportReportingFallbackPricingOnUnseededArchive -count=1
817+
```
818+
819+
Expected: FAIL with zero exported cost because reporting has not installed the
820+
empty-catalog in-memory fallback.
821+
822+
- [x] **Step 3: Prepare pricing during database open**
823+
824+
In `openReportingExportDB`, after `openExportReadOnlyDB` succeeds, call:
825+
826+
```go
827+
if err := ensureExportSessionsPricing(
828+
cmd.Context(), database, appConfig,
829+
); err != nil {
830+
_ = database.Close()
831+
return nil, func() {}, err
832+
}
833+
```
834+
835+
Return the existing cleanup closure only after preparation succeeds. This keeps
836+
the archive read-only, retains stored pricing when present, and prevents a
837+
database leak on setup failure.
838+
839+
- [x] **Step 4: Run the command regression and neighboring tests**
840+
841+
Run:
842+
843+
```bash
844+
CGO_ENABLED=1 go test -tags fts5 ./cmd/agentsview \
845+
-run 'TestExportReportingFallbackPricingOnUnseededArchive|TestExportHour|TestExportDay|TestExportDigest|TestExportReportingSchemaVersion' \
846+
-count=1
847+
```
848+
849+
Expected: PASS.
850+
851+
- [x] **Step 5: Remove the temporary design document**
852+
853+
Delete only:
854+
855+
```text
856+
docs/superpowers/specs/2026-07-30-reporting-export-contract-corrections-design.md
857+
```
858+
859+
Do not remove unrelated Superpowers plans or specifications.
860+
861+
- [ ] **Step 6: Verify, audit, commit, and push**
862+
863+
Run:
864+
865+
```bash
866+
go fmt ./...
867+
CGO_ENABLED=1 go test -tags fts5 ./...
868+
go vet ./...
869+
```
870+
871+
Inspect the complete staged diff and scan added strings for private identities,
872+
hosts, repositories, paths, credentials, or workflow-specific context. Verify
873+
the reporting fixture manifest remains valid. Use the mandatory commit skill
874+
and commit the implementation, regressions, plan update, and requested design
875+
document removal with:
876+
877+
```text
878+
fix(export): align reporting usage reconciliation
879+
```
880+
881+
Push normally to `origin/agent/hourly-reporting-export`. Do not force-push,
882+
rewrite history, or post pull-request comments.

0 commit comments

Comments
 (0)