diff --git a/apps/backend/internal/backendapp/adapters_usage_pricing.go b/apps/backend/internal/backendapp/adapters_usage_pricing.go new file mode 100644 index 0000000000..6aeeb2b172 --- /dev/null +++ b/apps/backend/internal/backendapp/adapters_usage_pricing.go @@ -0,0 +1,34 @@ +package backendapp + +import ( + "context" + + commoncosts "github.com/kandev/kandev/internal/common/costs" + officeshared "github.com/kandev/kandev/internal/office/shared" +) + +// modelsDevLookup is the narrow slice of *modelsdev.Client (internal/office/ +// costs/modelsdev) this adapter needs, defined locally so it can be +// exercised with a stub in tests without constructing a real Client. +type modelsDevLookup interface { + LookupForModelWithVersion(ctx context.Context, modelID string) (officeshared.ModelPricing, string, bool) +} + +// usagePricingAdapter adapts *modelsdev.Client's office/shared.ModelPricing- +// returning LookupForModelWithVersion to internal/task/usage's own +// PricingLookup interface (docs/specs/task-cost-ledger/spec.md AC-26): the +// ledger writer's package boundary must never import internal/office/**, so +// this conversion lives in the composition root instead. +type usagePricingAdapter struct { + lookup modelsDevLookup +} + +func (a usagePricingAdapter) LookupForModelWithVersion(ctx context.Context, modelID string) (commoncosts.ModelPricing, string, bool) { + pricing, version, ok := a.lookup.LookupForModelWithVersion(ctx, modelID) + return commoncosts.ModelPricing{ + InputPerMillion: pricing.InputPerMillion, + CachedReadPerMillion: pricing.CachedReadPerMillion, + CachedWritePerMillion: pricing.CachedWritePerMillion, + OutputPerMillion: pricing.OutputPerMillion, + }, version, ok +} diff --git a/apps/backend/internal/backendapp/adapters_usage_pricing_test.go b/apps/backend/internal/backendapp/adapters_usage_pricing_test.go new file mode 100644 index 0000000000..9102977b2d --- /dev/null +++ b/apps/backend/internal/backendapp/adapters_usage_pricing_test.go @@ -0,0 +1,60 @@ +package backendapp + +import ( + "context" + "testing" + + officeshared "github.com/kandev/kandev/internal/office/shared" +) + +type stubModelsDevLookup struct { + pricing officeshared.ModelPricing + version string + ok bool +} + +func (s stubModelsDevLookup) LookupForModelWithVersion(_ context.Context, _ string) (officeshared.ModelPricing, string, bool) { + return s.pricing, s.version, s.ok +} + +// TestUsagePricingAdapter_ConvertsSharedModelPricingFieldForField pins +// docs/specs/task-cost-ledger/spec.md AC-26: the adapter must translate +// *modelsdev.Client's office/shared.ModelPricing into internal/task/usage's +// own commoncosts.ModelPricing verbatim, field for field, so the ledger +// writer's pricing resolution never depends on internal/office/** types. +func TestUsagePricingAdapter_ConvertsSharedModelPricingFieldForField(t *testing.T) { + stub := stubModelsDevLookup{ + pricing: officeshared.ModelPricing{ + InputPerMillion: 111, + CachedReadPerMillion: 222, + CachedWritePerMillion: 333, + OutputPerMillion: 444, + }, + version: "models-dev-2026-08-23", + ok: true, + } + adapter := usagePricingAdapter{lookup: stub} + + got, version, ok := adapter.LookupForModelWithVersion(context.Background(), "claude-sonnet-5") + if !ok { + t.Fatal("ok = false, want true") + } + if version != "models-dev-2026-08-23" { + t.Errorf("version = %q, want models-dev-2026-08-23", version) + } + if got.InputPerMillion != 111 || got.CachedReadPerMillion != 222 || got.CachedWritePerMillion != 333 || got.OutputPerMillion != 444 { + t.Errorf("got = %+v, want fields copied verbatim from office/shared.ModelPricing", got) + } +} + +// TestUsagePricingAdapter_PassesThroughNotOK pins the miss path: an unknown +// model must surface as ok=false, not a zero-valued "success". +func TestUsagePricingAdapter_PassesThroughNotOK(t *testing.T) { + stub := stubModelsDevLookup{ok: false} + adapter := usagePricingAdapter{lookup: stub} + + _, _, ok := adapter.LookupForModelWithVersion(context.Background(), "unknown-model") + if ok { + t.Fatal("ok = true, want false when the underlying lookup misses") + } +} diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 2df6b50679..8388ce24bf 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -1181,6 +1181,31 @@ func initOfficeServices( // at all: blocked_by on create failed and list_related_tasks reported nothing. services.Task.SetBlockerRepository(repos.Office) + // office-costs Wave B: lazy models.dev pricing lookup. The Client + // allocates no resources at startup — the first non-claude-acp cost + // event triggers a disk read; the first missing cache file triggers + // a background fetch. Workspaces running only claude-acp stay + // untouched because Layer A handles every event before lookup. + // + // Constructed here, above the Office early return, because + // task_usage_events's ledger writer (docs/specs/task-cost-ledger/spec.md + // AC-10, AC-26) needs it in every install, not just Office-enabled ones. + // SetPricingLookup/SetModelInfoLookup below stay gated - the Office + // service and orchestrator model-info surface those Office features + // widen only apply when the feature is on. + modelsdevCachePath := filepath.Join(cfg.ResolvedHomeDir(), "cache", "models-dev.json") + pricingLookup := officemodelsdev.New(officemodelsdev.Config{ + CachePath: modelsdevCachePath, + }, log) + + // The ledger writer is the sole writer of task_sessions' usage rollup + // columns (AC-10) and must run in every install, so it too is + // constructed and started before the Office early return. + if err := startTaskUsageWriter(repos.Task, usagePricingAdapter{lookup: pricingLookup}, eventBus, log, addCleanup); err != nil { + log.Error("Failed to subscribe task usage ledger writer", zap.Error(err)) + return nil, false + } + if !cfg.Features.Office { log.Info("Office feature disabled; Office services skipped while global run scheduling remains enabled") return runProcessorSvc, true @@ -1189,18 +1214,8 @@ func initOfficeServices( services.Office = runProcessorSvc log.Info("Office service constructed with all dependencies") - // office-costs Wave B: lazy models.dev pricing lookup. The Client - // allocates no resources at startup — the first non-claude-acp cost - // event triggers a disk read; the first missing cache file triggers - // a background fetch. Workspaces running only claude-acp stay - // untouched because Layer A handles every event before lookup. - modelsdevCachePath := filepath.Join(cfg.ResolvedHomeDir(), "cache", "models-dev.json") - pricingLookup := officemodelsdev.New(officemodelsdev.Config{ - CachePath: modelsdevCachePath, - }, log) services.Office.SetPricingLookup(pricingLookup) orchestratorSvc.SetModelInfoLookup(pricingLookup) - services.Office.SetSessionUsageWriter(repos.Task) // ADR 0005 Wave E: plug the runtime-tier skill deployer into the // lifecycle manager. The deployer reads office's skills repo + diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 970ef52a23..764e65cb3c 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -144,6 +144,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories StatusSummaries: repos.Task, TaskActivity: repos.Task, SubagentContexts: repos.Task, + Usage: repos.Task, }, eventBus, log, diff --git a/apps/backend/internal/backendapp/storage.go b/apps/backend/internal/backendapp/storage.go index 8bfb2ab9c6..670a2ead08 100644 --- a/apps/backend/internal/backendapp/storage.go +++ b/apps/backend/internal/backendapp/storage.go @@ -70,13 +70,6 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo return nil, nil, nil, fmt.Errorf("office repo: %w", err) } cleanups = append(cleanups, officeCleanup) - // Must run after office.Provide succeeds: it reconciles task_sessions - // against office's office_cost_events ledger, and construction order is - // what guarantees that table exists now - see the doc comment on - // sqlite.Repository.BackfillSessionTokensCachedIn. - if err := backfillSessionCachedTokens(ctx, taskRepoImpl, log); err != nil { - return nil, nil, nil, err - } terminalRepoImpl, err := terminalrepo.NewWithDB(writer, reader, log) if err != nil { return nil, nil, nil, fmt.Errorf("terminal repo: %w", err) @@ -133,30 +126,6 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo return pool, repos, cleanups, nil } -// sessionCachedTokenBackfiller is the narrow slice of *sqlite.Repository -// (internal/task/repository/sqlite) this composition root needs, defined -// locally so this file doesn't have to import that subpackage just for one -// call. See sqlite.Repository.BackfillSessionTokensCachedIn's doc comment -// for why this call lives here rather than inside the task repository's own -// runMigrations(): it reconciles task_sessions against the office -// repository's office_cost_events ledger, and only this composition root -// knows both repositories have finished initializing. -type sessionCachedTokenBackfiller interface { - BackfillSessionTokensCachedIn(ctx context.Context) (int64, error) -} - -// backfillSessionCachedTokens triggers the task repository's cached-token -// reconciliation and logs how many task_sessions rows it reconciled. -// Extracted out of provideRepositories to keep it within the funlen limit. -func backfillSessionCachedTokens(ctx context.Context, taskRepo sessionCachedTokenBackfiller, log *logger.Logger) error { - affected, err := taskRepo.BackfillSessionTokensCachedIn(ctx) - if err != nil { - return err - } - log.Info("reconciled task_sessions.tokens_cached_in against office_cost_events", zap.Int64("rows", affected)) - return nil -} - // supportRepositorySet groups the lighter-weight support repositories // that share a common (writer, reader) wire-up pattern. type supportRepositorySet struct { diff --git a/apps/backend/internal/backendapp/storage_cached_tokens_test.go b/apps/backend/internal/backendapp/storage_cached_tokens_test.go deleted file mode 100644 index 5aa4e42dd4..0000000000 --- a/apps/backend/internal/backendapp/storage_cached_tokens_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package backendapp - -import ( - "context" - "testing" - "time" - - "github.com/kandev/kandev/internal/common/config" -) - -// TestProvideRepositoriesBackfillsSessionCachedTokens is the composition-level -// regression test for moving task_sessions.tokens_cached_in's backfill -// trigger out of the task repository's own runMigrations() and into this -// package (see the "PR Fixup" plan section on carlosflorencio's PR #2521 -// architecture comment). Neither repository's own package tests can see the -// other's construction order, so only a test that boots the real repository -// graph through provideRepositories can prove the backfill still actually -// runs — a regression here would silently resurrect the original card's bug -// (task_sessions.tokens_cached_in drifting from the office_cost_events -// ledger) without any single-package test failing. -func TestProvideRepositoriesBackfillsSessionCachedTokens(t *testing.T) { - ctx := context.Background() - cfg := &config.Config{ - HomeDir: t.TempDir(), - Database: config.DatabaseConfig{Driver: "sqlite"}, - } - log := newTestLogger() - - // First boot: creates the schema (both repositories' tables, including - // office_cost_events) against a real on-disk SQLite file. Then seed a - // drifted task_sessions row directly - bypassing IncrementTaskSessionUsage - // - the same way a missed live increment would leave one, plus a ledger - // row establishing the true value the next boot's backfill must recover. - pool, _, cleanups, err := provideRepositories(ctx, cfg, log, "test") - if err != nil { - t.Fatalf("provideRepositories (first boot): %v", err) - } - writer := pool.Writer() - now := time.Now().UTC() - if _, err := writer.Exec(writer.Rebind( - `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES (?, 'test workspace', ?, ?)`), - "ws-cached-boot", now, now); err != nil { - t.Fatalf("seed workspace: %v", err) - } - if _, err := writer.Exec(writer.Rebind( - `INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) VALUES (?, ?, 'test', ?, ?)`), - "task-cached-boot", "ws-cached-boot", now, now); err != nil { - t.Fatalf("seed task: %v", err) - } - if _, err := writer.Exec(writer.Rebind( - `INSERT INTO task_sessions (id, task_id, started_at, updated_at, tokens_cached_in) VALUES (?, ?, ?, ?, ?)`), - "sess-cached-boot", "task-cached-boot", now, now, 2_000); err != nil { - t.Fatalf("seed drifted session: %v", err) - } - if _, err := writer.Exec(writer.Rebind( - `INSERT INTO office_cost_events (id, session_id, tokens_cached_in, occurred_at, created_at) VALUES (?, ?, ?, ?, ?)`), - "cost-cached-boot", "sess-cached-boot", 98_805_109, now, now); err != nil { - t.Fatalf("seed ledger row: %v", err) - } - for i := len(cleanups) - 1; i >= 0; i-- { - if cleanups[i] != nil { - _ = cleanups[i]() - } - } - - // Second boot against the same on-disk DB (same cfg.HomeDir, so the same - // kandev.db file) simulates a restart. The composition-level backfill - // call in provideRepositories must reconcile the drifted row above. - pool2, _, cleanups2, err := provideRepositories(ctx, cfg, log, "test") - if err != nil { - t.Fatalf("provideRepositories (second boot): %v", err) - } - t.Cleanup(func() { - for i := len(cleanups2) - 1; i >= 0; i-- { - if cleanups2[i] != nil { - _ = cleanups2[i]() - } - } - }) - - var got int64 - writer2 := pool2.Writer() - if err := writer2.QueryRowx(writer2.Rebind( - `SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-cached-boot", - ).Scan(&got); err != nil { - t.Fatalf("read row after second boot: %v", err) - } - if got != 98_805_109 { - t.Errorf("tokens_cached_in after reboot = %d, want 98805109 (the drifted 2000 must be "+ - "reconciled against the office_cost_events ledger by the composition-level backfill "+ - "call in provideRepositories, after office.Provide succeeds)", got) - } -} diff --git a/apps/backend/internal/backendapp/storage_telemetry_test.go b/apps/backend/internal/backendapp/storage_telemetry_test.go index 8fea7a54fd..cbdaaea204 100644 --- a/apps/backend/internal/backendapp/storage_telemetry_test.go +++ b/apps/backend/internal/backendapp/storage_telemetry_test.go @@ -20,8 +20,10 @@ import ( // still calls it. A regression that dropped the call site, or moved it ahead // of a repository's initSchema, would leave telemetry_activations silently // missing rows on every real boot with the rest of the suite fully green — -// mirrors TestProvideRepositoriesBackfillsSessionCachedTokens's rationale for -// the neighboring backfillSessionCachedTokens call. +// the same class of composition-level boot regression coverage this package +// uses elsewhere for provideRepositories' other post-initSchema side effects +// (see, for example, TestPostgresBootInitializesRepositories in +// postgres_boot_test.go). // // telemetry_activations itself is created unconditionally by // telemetrycontract.NewWithDB, independent of ordering, so a row-count diff --git a/apps/backend/internal/backendapp/storage_usage_rollup_boot_test.go b/apps/backend/internal/backendapp/storage_usage_rollup_boot_test.go new file mode 100644 index 0000000000..f0e3eb7791 --- /dev/null +++ b/apps/backend/internal/backendapp/storage_usage_rollup_boot_test.go @@ -0,0 +1,129 @@ +package backendapp + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/task/models" +) + +// TestProvideRepositoriesPreservesUsageRollupAcrossReboot closes Review +// Round 2's M3 finding: the task plan's AC-36 row claimed +// TestBackfillSkippedWhenTaskSessionsAlreadyHasUsageColumns (a boot-replay +// test) covered "rollup survives a reboot", but that test was deleted along +// with the one-time backfill migration it exercised (the backfill became +// unnecessary once task_usage_events shipped as a day-one feature with no +// pre-existing rollup data to migrate) - leaving no replacement proving a +// real second boot doesn't touch already-populated rollup columns. +// +// This test boots provideRepositories on a temp on-disk SQLite database, +// writes one ledger row through the real CreateTaskUsageEvent path (so +// task_sessions.tokens_in/tokens_cached_in/tokens_out/cost_subcents are +// populated exactly as production sets them), fully tears the boot down via +// its cleanups (closing the database), then calls provideRepositories again +// on the same on-disk file and asserts all four rollup columns are +// unchanged - schema init and migrations on the second boot must not reset, +// re-derive, or re-backfill them. +func TestProvideRepositoriesPreservesUsageRollupAcrossReboot(t *testing.T) { + ctx := context.Background() + cfg := &config.Config{ + HomeDir: t.TempDir(), + Database: config.DatabaseConfig{Driver: "sqlite"}, + } + + firstPool, firstRepos, firstCleanups, err := provideRepositories(ctx, cfg, nil, "test") + if err != nil { + t.Fatalf("first provideRepositories: %v", err) + } + _ = firstPool + firstClosed := false + closeFirst := func() { + if firstClosed { + return + } + firstClosed = true + for i := len(firstCleanups) - 1; i >= 0; i-- { + if firstCleanups[i] != nil { + _ = firstCleanups[i]() + } + } + } + t.Cleanup(closeFirst) + + if err := firstRepos.Task.CreateTask(ctx, &models.Task{ + ID: "task-reboot-rollup", + WorkspaceID: "ws-1", + Title: "Usage rollup reboot test task", + Priority: "medium", + }); err != nil { + t.Fatalf("CreateTask: %v", err) + } + if err := firstRepos.Task.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-reboot-rollup", + TaskID: "task-reboot-rollup", + State: models.TaskSessionStateWaitingForInput, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + + tokensCachedRead := int64(20) + tokensCachedWrite := int64(5) + tokensOut := int64(30) + if err := firstRepos.Task.CreateTaskUsageEvent(ctx, &models.TaskUsageEvent{ + UsageEventID: "evt-reboot-rollup", + TaskID: "task-reboot-rollup", + SessionID: "session-reboot-rollup", + AgentType: "claude", + Model: "claude-sonnet-5", + Provider: "anthropic", + TokensIn: 100, + TokensCachedRead: &tokensCachedRead, + TokensCachedWrite: &tokensCachedWrite, + TokensOut: &tokensOut, + TokensTotal: 155, + CostSubcents: 42, + CostSource: "actual", + ContractVersion: 1, + }); err != nil { + t.Fatalf("CreateTaskUsageEvent: %v", err) + } + + before, err := firstRepos.Task.GetTaskSession(ctx, "session-reboot-rollup") + if err != nil { + t.Fatalf("GetTaskSession before reboot: %v", err) + } + if before.TokensIn != 100 || before.TokensCachedIn != 25 || before.TokensOut != 30 || before.CostSubcents != 42 { + t.Fatalf("rollup before reboot = (%d,%d,%d,%d), want (100,25,30,42)", + before.TokensIn, before.TokensCachedIn, before.TokensOut, before.CostSubcents) + } + + // Tear the first boot fully down (closes the database) before reopening + // the same on-disk file, so the second provideRepositories call is a + // genuine cold reboot rather than a second handle onto a live pool. + closeFirst() + + _, secondRepos, secondCleanups, err := provideRepositories(ctx, cfg, nil, "test") + if err != nil { + t.Fatalf("second provideRepositories: %v", err) + } + t.Cleanup(func() { + for i := len(secondCleanups) - 1; i >= 0; i-- { + if secondCleanups[i] != nil { + _ = secondCleanups[i]() + } + } + }) + + after, err := secondRepos.Task.GetTaskSession(ctx, "session-reboot-rollup") + if err != nil { + t.Fatalf("GetTaskSession after reboot: %v", err) + } + if after.TokensIn != before.TokensIn || after.TokensCachedIn != before.TokensCachedIn || + after.TokensOut != before.TokensOut || after.CostSubcents != before.CostSubcents { + t.Errorf("rollup after reboot = (%d,%d,%d,%d), want unchanged (%d,%d,%d,%d): "+ + "a second boot's schema init/migrations must not reset or re-derive already-populated usage rollup columns", + after.TokensIn, after.TokensCachedIn, after.TokensOut, after.CostSubcents, + before.TokensIn, before.TokensCachedIn, before.TokensOut, before.CostSubcents) + } +} diff --git a/apps/backend/internal/backendapp/task_usage_wiring.go b/apps/backend/internal/backendapp/task_usage_wiring.go new file mode 100644 index 0000000000..5634e5a044 --- /dev/null +++ b/apps/backend/internal/backendapp/task_usage_wiring.go @@ -0,0 +1,27 @@ +package backendapp + +import ( + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" + taskusage "github.com/kandev/kandev/internal/task/usage" +) + +func startTaskUsageWriter( + repo taskusage.Repository, + pricing taskusage.PricingLookup, + eventBus bus.EventBus, + log *logger.Logger, + addCleanup func(func() error), +) error { + writer := taskusage.NewWriter(repo, pricing, log) + writer.Start() + if err := writer.Subscribe(eventBus); err != nil { + writer.Stop() + return err + } + addCleanup(func() error { + writer.Stop() + return nil + }) + return nil +} diff --git a/apps/backend/internal/backendapp/task_usage_wiring_test.go b/apps/backend/internal/backendapp/task_usage_wiring_test.go new file mode 100644 index 0000000000..083730c9a3 --- /dev/null +++ b/apps/backend/internal/backendapp/task_usage_wiring_test.go @@ -0,0 +1,98 @@ +package backendapp + +import ( + "context" + "go/ast" + "go/parser" + "go/token" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" +) + +type taskUsageWiringRepo struct { + recorded chan *models.TaskUsageEvent +} + +func (r *taskUsageWiringRepo) CreateTaskUsageEvent(_ context.Context, event *models.TaskUsageEvent) error { + r.recorded <- event + return nil +} + +func TestInitOfficeServicesUsesTaskUsageWiringHelper(t *testing.T) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "main.go", nil, 0) + if err != nil { + t.Fatalf("parse main.go: %v", err) + } + + var initOffice *ast.FuncDecl + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if ok && fn.Name.Name == "initOfficeServices" { + initOffice = fn + break + } + } + if initOffice == nil { + t.Fatal("initOfficeServices not found in main.go") + } + + var helperCalls int + ast.Inspect(initOffice, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + if name, ok := call.Fun.(*ast.Ident); ok && name.Name == "startTaskUsageWriter" { + helperCalls++ + } + return true + }) + if helperCalls != 1 { + t.Fatalf("startTaskUsageWriter calls = %d, want exactly one composition call", helperCalls) + } +} + +func TestStartTaskUsageWriter_PublishesIntoRepository(t *testing.T) { + repo := &taskUsageWiringRepo{recorded: make(chan *models.TaskUsageEvent, 1)} + eventBus := bus.NewMemoryEventBus(logger.Default()) + var cleanups []func() error + addCleanup := func(cleanup func() error) { + cleanups = append(cleanups, cleanup) + } + if err := startTaskUsageWriter(repo, nil, eventBus, nil, addCleanup); err != nil { + t.Fatalf("startTaskUsageWriter: %v", err) + } + t.Cleanup(func() { + for i := len(cleanups) - 1; i >= 0; i-- { + if err := cleanups[i](); err != nil { + t.Errorf("cleanup: %v", err) + } + } + }) + + if err := eventBus.Publish(context.Background(), events.BuildSessionPromptUsageSubject("session-1"), &bus.Event{ + Data: map[string]any{ + "task_id": "task-1", + "session_id": "session-1", + "usage_event_id": "evt-wiring", + "usage": map[string]any{"input_tokens": int64(7)}, + }, + }); err != nil { + t.Fatalf("Publish: %v", err) + } + + select { + case event := <-repo.recorded: + if event.UsageEventID != "evt-wiring" || event.TokensIn != 7 { + t.Fatalf("recorded event = %+v, want usage event evt-wiring with 7 input tokens", event) + } + case <-time.After(time.Second): + t.Fatal("task usage writer did not receive the published event") + } +} diff --git a/apps/backend/internal/common/costs/pricing.go b/apps/backend/internal/common/costs/pricing.go new file mode 100644 index 0000000000..7985482a3d --- /dev/null +++ b/apps/backend/internal/common/costs/pricing.go @@ -0,0 +1,122 @@ +// Package costs holds cost-arithmetic and provider-derivation logic shared +// across the backend and agentctl tiers (docs/specs/task-cost-ledger/spec.md). +// It must not import internal/office/** or internal/task/**: both the Office +// cost pipeline and the task usage ledger delegate to this package, not the +// other way around. +package costs + +import ( + "math" + "strings" +) + +// ModelPricing holds per-million-token pricing for a model. All units +// are hundredths of a cent (subcents) per million tokens — keeps the math +// integer-only and matches the storage unit on office_cost_events.cost_subcents +// and task_usage_events.cost_subcents. +type ModelPricing struct { + InputPerMillion int64 + CachedReadPerMillion int64 + CachedWritePerMillion int64 + OutputPerMillion int64 +} + +// CalculateCostSubcents computes estimated cost from token counts and pricing. +// All token counts are int64 to match the wire types from streams.PromptUsage. +// Returns 0 if pricing is the zero value or an intermediate operation +// overflows int64. Cached read and cached write are passed separately because +// Anthropic charges different rates (cached writes cost ~25% more than the +// base input rate). Call CalculateCostSubcentsChecked when the caller needs +// to distinguish overflow from a legitimate zero cost. +func CalculateCostSubcents( + tokensIn, tokensCachedRead, tokensCachedWrite, tokensOut int64, + pricing ModelPricing, +) int64 { + cost, ok := CalculateCostSubcentsChecked(tokensIn, tokensCachedRead, tokensCachedWrite, tokensOut, pricing) + if !ok { + return 0 + } + return cost +} + +// CalculateCostSubcentsChecked computes estimated cost and reports whether +// every intermediate operation fits in int64. A false result prevents a +// wrapped value from entering a durable cost ledger. +func CalculateCostSubcentsChecked( + tokensIn, tokensCachedRead, tokensCachedWrite, tokensOut int64, + pricing ModelPricing, +) (int64, bool) { + terms := [][2]int64{ + {tokensIn, pricing.InputPerMillion}, + {tokensCachedRead, pricing.CachedReadPerMillion}, + {tokensCachedWrite, pricing.CachedWritePerMillion}, + {tokensOut, pricing.OutputPerMillion}, + } + var total int64 + for _, term := range terms { + product, ok := checkedMul(term[0], term[1]) + if !ok { + return 0, false + } + total, ok = checkedAdd(total, product) + if !ok { + return 0, false + } + } + return total / 1_000_000, true +} + +func checkedAdd(a, b int64) (int64, bool) { + if b > 0 && a > math.MaxInt64-b { + return 0, false + } + if b < 0 && a < math.MinInt64-b { + return 0, false + } + return a + b, true +} + +func checkedMul(a, b int64) (int64, bool) { + if a == 0 || b == 0 { + return 0, true + } + if (a == math.MinInt64 && b == -1) || (b == math.MinInt64 && a == -1) { + return 0, false + } + product := a * b + if product/b != a { + return 0, false + } + return product, true +} + +// ProviderForModel returns a best-guess provider id for a model name, used +// when the CLI payload doesn't already carry a provider (it does for claude-acp +// once the subscriber sets it from the CLI id). Returns "" when the prefix is +// unknown. +func ProviderForModel(model string) string { + switch { + case strings.HasPrefix(model, "claude"): + return "anthropic" + case strings.HasPrefix(model, "gpt") || strings.HasPrefix(model, "o3") || strings.HasPrefix(model, "o4"): + return "openai" + case strings.HasPrefix(model, "gemini"): + return "google" + } + return "" +} + +// ProviderFromCLI maps the upstream CLI id (the agent_id/agent_type stream +// field) to a provider name. Used because claude-acp emits logical model +// aliases (sonnet / haiku) that can't be matched on prefix. +func ProviderFromCLI(cli string) string { + switch cli { + case "claude-acp": + return "anthropic" + case "codex-acp", "openai-acp": + return "openai" + case "gemini", "gemini-acp": + return "google" + } + return "" +} diff --git a/apps/backend/internal/common/costs/pricing_test.go b/apps/backend/internal/common/costs/pricing_test.go new file mode 100644 index 0000000000..29c5a1ef7c --- /dev/null +++ b/apps/backend/internal/common/costs/pricing_test.go @@ -0,0 +1,108 @@ +package costs_test + +import ( + "math" + "testing" + + "github.com/kandev/kandev/internal/common/costs" +) + +func TestCalculateCostSubcents(t *testing.T) { + // Anthropic-ish rates: $3/M input, $0.30/M cached-read, $3.75/M cached-write, + // $15/M output. In subcents/M that's 30000, 3000, 37500, 150000. + pricing := costs.ModelPricing{ + InputPerMillion: 30000, + CachedReadPerMillion: 3000, + CachedWritePerMillion: 37500, + OutputPerMillion: 150000, + } + + tests := []struct { + name string + in, cachedRead, cachedWrite, out int64 + want int64 + }{ + {"zero tokens => zero cost", 0, 0, 0, 0, 0}, + {"input only 1M => 30000 subcents = $3", 1_000_000, 0, 0, 0, 30000}, + {"output only 1M => 150000 subcents = $15", 0, 0, 0, 1_000_000, 150000}, + {"cached read vs cached write priced separately", + 0, 1_000_000, 1_000_000, 0, + 3000 + 37500}, + {"mixed: realistic turn", + 500, 1000, 2000, 100, + (500*30000 + 1000*3000 + 2000*37500 + 100*150000) / 1_000_000}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := costs.CalculateCostSubcents(tc.in, tc.cachedRead, tc.cachedWrite, tc.out, pricing) + if got != tc.want { + t.Errorf("CalculateCostSubcents() = %d, want %d", got, tc.want) + } + }) + } +} + +func TestCalculateCostSubcents_ZeroPricing(t *testing.T) { + if got := costs.CalculateCostSubcents(1_000_000, 0, 0, 1_000_000, costs.ModelPricing{}); got != 0 { + t.Errorf("zero pricing should yield zero cost, got %d", got) + } +} + +func TestCalculateCostSubcents_OverflowReturnsZero(t *testing.T) { + pricing := costs.ModelPricing{InputPerMillion: 3} + checked, ok := costs.CalculateCostSubcentsChecked(math.MaxInt64/2+1, 0, 0, 0, pricing) + if ok || checked != 0 { + t.Fatalf("checked overflow result = (%d, %t), want (0, false)", checked, ok) + } + got := costs.CalculateCostSubcents(math.MaxInt64/2+1, 0, 0, 0, pricing) + if got != 0 { + t.Fatalf("overflowing cost = %d, want zero instead of a wrapped value", got) + } +} + +func TestProviderForModel(t *testing.T) { + tests := []struct { + model, want string + }{ + {"claude-opus-4-7", "anthropic"}, + {"claude-sonnet-4-5", "anthropic"}, + {"gpt-5-mini", "openai"}, + {"gpt-4.1", "openai"}, + {"o3-mini", "openai"}, + {"o4-mini", "openai"}, + {"gemini-2.5-pro", "google"}, + {"gemini-3-pro-preview", "google"}, + {"butler_a", ""}, + {"kimi-k2p6", ""}, + {"", ""}, + } + for _, tc := range tests { + t.Run(tc.model, func(t *testing.T) { + if got := costs.ProviderForModel(tc.model); got != tc.want { + t.Errorf("ProviderForModel(%q) = %q, want %q", tc.model, got, tc.want) + } + }) + } +} + +func TestProviderFromCLI(t *testing.T) { + tests := []struct { + cli, want string + }{ + {"claude-acp", "anthropic"}, + {"codex-acp", "openai"}, + {"openai-acp", "openai"}, + {"gemini", "google"}, + {"gemini-acp", "google"}, + {"unknown-cli", ""}, + {"", ""}, + } + for _, tc := range tests { + t.Run(tc.cli, func(t *testing.T) { + if got := costs.ProviderFromCLI(tc.cli); got != tc.want { + t.Errorf("ProviderFromCLI(%q) = %q, want %q", tc.cli, got, tc.want) + } + }) + } +} diff --git a/apps/backend/internal/db/migration_errors.go b/apps/backend/internal/db/migration_errors.go index 8a9ab9ae59..a5372ea60f 100644 --- a/apps/backend/internal/db/migration_errors.go +++ b/apps/backend/internal/db/migration_errors.go @@ -8,11 +8,17 @@ import ( ) const ( - postgresDuplicateColumn = "42701" - postgresDuplicateTable = "42P07" - postgresDuplicateObject = "42710" - postgresUndefinedTable = "42P01" - sqliteAlreadyExistsText = " already exists" + postgresDuplicateColumn = "42701" + postgresDuplicateTable = "42P07" + postgresDuplicateObject = "42710" + postgresUndefinedTable = "42P01" + postgresForeignKeyViolation = "23503" + postgresSerializationFailure = "40001" + postgresDeadlockDetected = "40P01" + sqliteAlreadyExistsText = " already exists" + sqliteForeignKeyViolationMsg = "FOREIGN KEY constraint failed" + sqliteBusyText = "database is locked" + sqliteLockedText = "database table is locked" ) // IsDuplicateColumnError reports whether err means an ADD COLUMN migration has @@ -72,3 +78,37 @@ func IsMissingTableError(err error) bool { } return strings.Contains(err.Error(), "no such table") } + +// IsForeignKeyViolation reports whether err means a write failed because it +// referenced a row that does not exist (or no longer exists) in a +// foreign-keyed table. +func IsForeignKeyViolation(err error) bool { + if err == nil { + return false + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == postgresForeignKeyViolation + } + return strings.Contains(err.Error(), sqliteForeignKeyViolationMsg) +} + +// IsTransientError reports whether err means a write failed for a reason +// that is expected to clear on retry: a serialization failure or deadlock on +// PostgreSQL, or the single-writer connection being busy/locked on SQLite. +func IsTransientError(err error) bool { + if err == nil { + return false + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case postgresSerializationFailure, postgresDeadlockDetected: + return true + default: + return false + } + } + s := err.Error() + return strings.Contains(s, sqliteBusyText) || strings.Contains(s, sqliteLockedText) +} diff --git a/apps/backend/internal/db/migration_errors_test.go b/apps/backend/internal/db/migration_errors_test.go index 49d25979be..91555dd222 100644 --- a/apps/backend/internal/db/migration_errors_test.go +++ b/apps/backend/internal/db/migration_errors_test.go @@ -195,3 +195,127 @@ func TestIsMissingTableError(t *testing.T) { }) } } + +func TestIsForeignKeyViolation(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "postgres foreign key violation", + err: &pgconn.PgError{Code: postgresForeignKeyViolation}, + want: true, + }, + { + name: "wrapped postgres foreign key violation", + err: fmt.Errorf("insert usage event: %w", &pgconn.PgError{Code: postgresForeignKeyViolation}), + want: true, + }, + { + name: "postgres unique violation is not a foreign key violation", + err: &pgconn.PgError{Code: "23505"}, + want: false, + }, + { + name: "sqlite foreign key constraint failed", + err: errors.New("FOREIGN KEY constraint failed"), + want: true, + }, + { + name: "wrapped sqlite foreign key constraint failed", + err: fmt.Errorf("insert usage event: %w", errors.New("FOREIGN KEY constraint failed")), + want: true, + }, + { + name: "sqlite unique constraint is not a foreign key violation", + err: errors.New("UNIQUE constraint failed: task_usage_events.usage_event_id"), + want: false, + }, + { + name: "unrelated", + err: errors.New("no such table: task_usage_events"), + want: false, + }, + { + name: "nil", + err: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsForeignKeyViolation(tt.err); got != tt.want { + t.Fatalf("IsForeignKeyViolation() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsTransientError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "postgres serialization failure", + err: &pgconn.PgError{Code: postgresSerializationFailure}, + want: true, + }, + { + name: "postgres deadlock detected", + err: &pgconn.PgError{Code: postgresDeadlockDetected}, + want: true, + }, + { + name: "wrapped postgres serialization failure", + err: fmt.Errorf("insert usage event: %w", &pgconn.PgError{Code: postgresSerializationFailure}), + want: true, + }, + { + name: "postgres foreign key violation is not transient", + err: &pgconn.PgError{Code: postgresForeignKeyViolation}, + want: false, + }, + { + name: "sqlite busy", + err: errors.New("database is locked"), + want: true, + }, + { + name: "sqlite locked", + err: errors.New("database table is locked"), + want: true, + }, + { + name: "wrapped sqlite busy", + err: fmt.Errorf("insert usage event: %w", errors.New("database is locked")), + want: true, + }, + { + name: "sqlite constraint error is not transient", + err: errors.New("UNIQUE constraint failed: task_usage_events.usage_event_id"), + want: false, + }, + { + name: "unrelated", + err: errors.New("no such table: task_usage_events"), + want: false, + }, + { + name: "nil", + err: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsTransientError(tt.err); got != tt.want { + t.Fatalf("IsTransientError() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/office/costs/modelsdev/client.go b/apps/backend/internal/office/costs/modelsdev/client.go index fb4214dcc4..a36e5ed0e3 100644 --- a/apps/backend/internal/office/costs/modelsdev/client.go +++ b/apps/backend/internal/office/costs/modelsdev/client.go @@ -262,7 +262,7 @@ func (c *Client) warmFromDisk(ctx context.Context) { } // File missing on first boot — schedule a refresh; lookup // returns miss this turn. - c.startBackgroundRefresh(ctx) + c.startBackgroundRefresh(context.WithoutCancel(ctx)) return } buf, err := os.ReadFile(c.cachePath) diff --git a/apps/backend/internal/office/costs/pricing.go b/apps/backend/internal/office/costs/pricing.go index 2743418f1b..e82d8cc3d9 100644 --- a/apps/backend/internal/office/costs/pricing.go +++ b/apps/backend/internal/office/costs/pricing.go @@ -1,45 +1,33 @@ package costs -import "strings" +import commoncosts "github.com/kandev/kandev/internal/common/costs" -// ModelPricing holds per-million-token pricing for a model. All units -// are hundredths of a cent (subcents) per million tokens — keeps the math -// integer-only and matches the storage unit on office_cost_events.cost_subcents. -type ModelPricing struct { - InputPerMillion int64 - CachedReadPerMillion int64 - CachedWritePerMillion int64 - OutputPerMillion int64 -} +// ModelPricing is an alias of the shared costs package's type +// (docs/specs/task-cost-ledger/spec.md) so existing costs.ModelPricing{...} +// call sites and struct literals in this package keep working unchanged. +type ModelPricing = commoncosts.ModelPricing -// CalculateCostSubcents computes estimated cost from token counts and pricing. -// All token counts are int64 to match the wire types from streams.PromptUsage. -// Returns 0 if pricing is the zero value. Cached read and cached write are -// passed separately because Anthropic charges different rates (cached writes -// cost ~25% more than the base input rate). +// CalculateCostSubcents delegates to internal/common/costs, the single +// source of truth for the cost formula shared with the task usage ledger +// writer. func CalculateCostSubcents( tokensIn, tokensCachedRead, tokensCachedWrite, tokensOut int64, pricing ModelPricing, ) int64 { - cost := tokensIn*pricing.InputPerMillion + - tokensCachedRead*pricing.CachedReadPerMillion + - tokensCachedWrite*pricing.CachedWritePerMillion + - tokensOut*pricing.OutputPerMillion - return cost / 1_000_000 + return commoncosts.CalculateCostSubcents(tokensIn, tokensCachedRead, tokensCachedWrite, tokensOut, pricing) } -// ProviderForModel returns a best-guess provider id for a model name, used -// when the CLI payload doesn't already carry a provider (it does for claude-acp -// once the subscriber sets it from the CLI id). Returns "" when the prefix is -// unknown. -func ProviderForModel(model string) string { - switch { - case strings.HasPrefix(model, "claude"): - return "anthropic" - case strings.HasPrefix(model, "gpt") || strings.HasPrefix(model, "o3") || strings.HasPrefix(model, "o4"): - return "openai" - case strings.HasPrefix(model, "gemini"): - return "google" - } - return "" +// CalculateCostSubcentsChecked exposes the shared overflow-aware calculation +// to Office callers that need to distinguish an unpriced result from a real +// zero-cost calculation. +func CalculateCostSubcentsChecked( + tokensIn, tokensCachedRead, tokensCachedWrite, tokensOut int64, + pricing ModelPricing, +) (int64, bool) { + return commoncosts.CalculateCostSubcentsChecked(tokensIn, tokensCachedRead, tokensCachedWrite, tokensOut, pricing) +} + +// ProviderForModel delegates to internal/common/costs. +func ProviderForModel(model string) string { + return commoncosts.ProviderForModel(model) } diff --git a/apps/backend/internal/office/repository/sqlite/base.go b/apps/backend/internal/office/repository/sqlite/base.go index f6d341373e..8e3800a8dd 100644 --- a/apps/backend/internal/office/repository/sqlite/base.go +++ b/apps/backend/internal/office/repository/sqlite/base.go @@ -109,17 +109,6 @@ func (r *Repository) ExecRaw(ctx context.Context, query string, args ...interfac // package should keep using typed methods. func (r *Repository) ReaderDB() *sqlx.DB { return r.ro } -// BeginTx starts a transaction on the shared writer connection. Exposed so -// the office service layer can make CreateCostEventTx atomic with another -// package's write (e.g. task.Repository.IncrementTaskSessionUsageTx via -// shared.SessionUsageWriterTx) — safe because both repositories are -// constructed from the same underlying *sqlx.DB (internal/backendapp/storage.go), -// so a transaction begun here can execute statements against tables either -// package owns. -func (r *Repository) BeginTx(ctx context.Context) (*sqlx.Tx, error) { - return r.db.BeginTxx(ctx, nil) -} - // initSchema creates all office tables if they don't exist. func (r *Repository) initSchema() error { if err := r.createCoreTables(); err != nil { @@ -311,13 +300,7 @@ func (r *Repository) createCostTables() error { CREATE INDEX IF NOT EXISTS idx_office_cost_agent ON office_cost_events(agent_profile_id); CREATE INDEX IF NOT EXISTS idx_office_cost_occurred ON office_cost_events(occurred_at DESC); CREATE INDEX IF NOT EXISTS idx_office_cost_task ON office_cost_events(task_id); - -- Indexes the join column internal/task/repository/sqlite's - -- BackfillSessionTokensCachedIn correlates task_sessions against. Lives - -- here (not in the task repository) because it indexes a table this - -- repository owns. Without it that correlated subquery falls back to a - -- full table scan per task_sessions row - measured at 76.72s at a modest - -- 4,000 sessions / 80,000 events versus 0.17s indexed. - CREATE INDEX IF NOT EXISTS idx_office_cost_events_session_id ON office_cost_events(session_id); + DROP INDEX IF EXISTS idx_office_cost_events_session_id; -- uniq_office_cost_usage_event is created by migrateCostEventContract -- (base_migrations.go), not here: schema init runs before migrations, -- so indexing usage_event_id inline would crash a pre-migration diff --git a/apps/backend/internal/office/repository/sqlite/cost_event_tx_atomicity_test.go b/apps/backend/internal/office/repository/sqlite/cost_event_tx_atomicity_test.go deleted file mode 100644 index 2b184663ac..0000000000 --- a/apps/backend/internal/office/repository/sqlite/cost_event_tx_atomicity_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package sqlite_test - -import ( - "context" - "testing" - "time" - - "github.com/jmoiron/sqlx" - _ "github.com/mattn/go-sqlite3" - - "github.com/kandev/kandev/internal/office/models" - "github.com/kandev/kandev/internal/office/repository/sqlite" - taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" -) - -// TestCostEventTxAtomicity_RealReposShareTransaction is the PR #2606 review -// round 1 follow-up (F2): the only prior coverage of -// recordCostEventAndRollup's shared-transaction path -// (event_subscribers.go) used a test double that discarded its *sqlx.Tx -// parameter, so neither session.go's `tx != nil` branch nor -// CreateCostEventTx executing against a real transaction was ever exercised. -// -// This builds the real office and task repositories over one *sqlx.DB (the -// NewWithDB(db, db, nil) pattern already used by -// cost_event_contract_migration_test.go and workflow_test.go — safe because -// office.Repository.BeginTx and task.Repository.IncrementTaskSessionUsageTx -// are constructed from the same underlying writer connection in production, -// see BeginTx's doc comment), then drives BeginTx -> CreateCostEventTx -> -// IncrementTaskSessionUsageTx -> Rollback and asserts both office_cost_events -// and task_sessions are unchanged, followed by a committed retry that -// updates both. -func TestCostEventTxAtomicity_RealReposShareTransaction(t *testing.T) { - db, err := sqlx.Open("sqlite3", ":memory:") - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - db.SetMaxOpenConns(1) - t.Cleanup(func() { _ = db.Close() }) - - taskRepo, err := taskrepo.NewWithDB(db, db, nil) - if err != nil { - t.Fatalf("init task repo: %v", err) - } - officeRepo, err := sqlite.NewWithDB(db, db, nil) - if err != nil { - t.Fatalf("init office repo: %v", err) - } - - ctx := context.Background() - now := time.Now().UTC() - if _, err := db.ExecContext(ctx, ` - INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-atomic', 'ws', ?, ?) - `, now, now); err != nil { - t.Fatalf("seed workspace: %v", err) - } - if _, err := db.ExecContext(ctx, ` - INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) VALUES ('task-atomic', 'ws-atomic', 't', ?, ?) - `, now, now); err != nil { - t.Fatalf("seed task: %v", err) - } - if _, err := db.ExecContext(ctx, ` - INSERT INTO task_sessions (id, task_id, started_at, updated_at) VALUES ('session-atomic', 'task-atomic', ?, ?) - `, now, now); err != nil { - t.Fatalf("seed session: %v", err) - } - - countCostEvents := func() int { - t.Helper() - var n int - if err := db.GetContext(ctx, &n, `SELECT COUNT(*) FROM office_cost_events`); err != nil { - t.Fatalf("count cost events: %v", err) - } - return n - } - rollupTotals := func() (tokensIn, tokensCachedIn, tokensOut, costSubcents int64) { - t.Helper() - row := db.QueryRowContext(ctx, ` - SELECT tokens_in, tokens_cached_in, tokens_out, cost_subcents - FROM task_sessions WHERE id = 'session-atomic' - `) - if err := row.Scan(&tokensIn, &tokensCachedIn, &tokensOut, &costSubcents); err != nil { - t.Fatalf("read rollup totals: %v", err) - } - return tokensIn, tokensCachedIn, tokensOut, costSubcents - } - buildEvent := func(usageEventID string) *models.CostEvent { - return &models.CostEvent{ - SessionID: "session-atomic", - TaskID: "task-atomic", - CostSubcents: 500, - UsageEventID: &usageEventID, - OccurredAt: now, - } - } - - // Rollback: both writes land in the same transaction; rolling it back - // must undo both, not just the one whose caller happened to fail. - tx, err := officeRepo.BeginTx(ctx) - if err != nil { - t.Fatalf("begin tx: %v", err) - } - if err := officeRepo.CreateCostEventTx(ctx, tx, buildEvent("usage-evt-rollback")); err != nil { - t.Fatalf("create cost event in tx: %v", err) - } - if err := taskRepo.IncrementTaskSessionUsageTx(ctx, tx, "session-atomic", 100, 40, 50, 500); err != nil { - t.Fatalf("increment rollup in tx: %v", err) - } - if err := tx.Rollback(); err != nil { - t.Fatalf("rollback: %v", err) - } - - if n := countCostEvents(); n != 0 { - t.Fatalf("cost events after rollback = %d, want 0", n) - } - if tokensIn, tokensCachedIn, tokensOut, costSubcents := rollupTotals(); tokensIn != 0 || tokensCachedIn != 0 || tokensOut != 0 || costSubcents != 0 { - t.Fatalf("rollup totals after rollback = (in=%d cached=%d out=%d cost=%d), want all zero", - tokensIn, tokensCachedIn, tokensOut, costSubcents) - } - - // Commit: a fresh transaction with the same shape commits both writes - // together, proving the office-begun transaction can actually reach - // task_sessions, a table the task package owns. - tx2, err := officeRepo.BeginTx(ctx) - if err != nil { - t.Fatalf("begin tx (commit path): %v", err) - } - if err := officeRepo.CreateCostEventTx(ctx, tx2, buildEvent("usage-evt-commit")); err != nil { - t.Fatalf("create cost event in tx (commit path): %v", err) - } - if err := taskRepo.IncrementTaskSessionUsageTx(ctx, tx2, "session-atomic", 100, 40, 50, 500); err != nil { - t.Fatalf("increment rollup in tx (commit path): %v", err) - } - if err := tx2.Commit(); err != nil { - t.Fatalf("commit: %v", err) - } - - if n := countCostEvents(); n != 1 { - t.Fatalf("cost events after commit = %d, want 1", n) - } - if tokensIn, tokensCachedIn, tokensOut, costSubcents := rollupTotals(); tokensIn != 100 || tokensCachedIn != 40 || tokensOut != 50 || costSubcents != 500 { - t.Fatalf("rollup totals after commit = (in=%d cached=%d out=%d cost=%d), want (100, 40, 50, 500)", - tokensIn, tokensCachedIn, tokensOut, costSubcents) - } -} diff --git a/apps/backend/internal/office/repository/sqlite/costs.go b/apps/backend/internal/office/repository/sqlite/costs.go index ab356c30cb..9d804f05a6 100644 --- a/apps/backend/internal/office/repository/sqlite/costs.go +++ b/apps/backend/internal/office/repository/sqlite/costs.go @@ -2,14 +2,12 @@ package sqlite import ( "context" - "database/sql" "errors" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgconn" - "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/office/models" ) @@ -59,39 +57,19 @@ func isUsageEventUniqueViolation(err error) bool { // CreateCostEvent records a new cost event. A UsageEventID collision // (redelivery of the same prompt-usage event) is reported as // ErrDuplicateUsageEvent rather than the raw driver error, so callers can -// treat it as an idempotent no-op. -// -// Delegates to CreateCostEventTx using r.db as the executor; a caller that -// needs this atomic with another write (e.g. the office cost subscriber's -// session-usage rollup) should call the Tx variant directly with a shared -// transaction instead. +// treat it as an idempotent no-op. Office's cost subscriber no longer +// shares this insert with a task_sessions rollup write (that pairing now +// happens entirely inside internal/task/usage's writer via its own +// insertUsageEventAndRollup — docs/specs/task-cost-ledger/spec.md AC-10, +// AC-21), so this always executes against r.db directly with no +// transaction parameter to plumb through. func (r *Repository) CreateCostEvent(ctx context.Context, event *models.CostEvent) error { - return r.CreateCostEventTx(ctx, nil, event) -} - -// CreateCostEventTx is CreateCostEvent's transactional twin: executes -// against tx when non-nil (falling back to r.db, the shared writer -// connection, when tx is nil) so a caller can make this atomic with another -// write in the same transaction — see BeginTx and -// shared.SessionUsageWriterTx's doc comment for why (docs/specs/office/costs.md, -// PR #2606 review). -func (r *Repository) CreateCostEventTx(ctx context.Context, tx *sqlx.Tx, event *models.CostEvent) error { if event.ID == "" { event.ID = uuid.New().String() } event.CreatedAt = time.Now().UTC() - var exec interface { - ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) - Rebind(query string) string - } - if tx != nil { - exec = tx - } else { - exec = r.db - } - - _, err := exec.ExecContext(ctx, exec.Rebind(` + _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO office_cost_events ( id, session_id, task_id, agent_profile_id, project_id, model, provider, tokens_in, tokens_cached_in, diff --git a/apps/backend/internal/office/repository/sqlite/costs_test.go b/apps/backend/internal/office/repository/sqlite/costs_test.go index f3b7fb430f..73e88fa5c6 100644 --- a/apps/backend/internal/office/repository/sqlite/costs_test.go +++ b/apps/backend/internal/office/repository/sqlite/costs_test.go @@ -2,6 +2,7 @@ package sqlite_test import ( "context" + "database/sql" "errors" "testing" "time" @@ -36,22 +37,15 @@ func seedTasksTable(t *testing.T, repo *sqlite.Repository, taskID, workspaceID s } } -// TestCreateCostTables_IndexesSessionID guards the boot-time cost of -// internal/task/repository/sqlite's BackfillSessionTokensCachedIn, which -// correlates task_sessions against office_cost_events on session_id. Without -// this index that correlated subquery falls back to a full table scan per -// task_sessions row (a measured ~450x slowdown at 4,000 sessions / 80,000 -// events - 76.72s vs 0.17s). The index lives here, not in the task -// repository, because it indexes a table this repository owns. -func TestCreateCostTables_IndexesSessionID(t *testing.T) { +func TestCreateCostTables_DoesNotCreateUnusedSessionIDIndex(t *testing.T) { repo := newTestRepo(t) var name string err := repo.ReaderDB().QueryRow( `SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'office_cost_events' AND sql LIKE '%session_id%'`, ).Scan(&name) - if err != nil { - t.Fatalf("expected an index on office_cost_events(session_id) to exist after repo init, got: %v", err) + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("session_id index = %q, err=%v, want no index", name, err) } } diff --git a/apps/backend/internal/office/service/base_test.go b/apps/backend/internal/office/service/base_test.go index 8f26e3501a..db33731e5d 100644 --- a/apps/backend/internal/office/service/base_test.go +++ b/apps/backend/internal/office/service/base_test.go @@ -98,11 +98,17 @@ func newTestService(t *testing.T, overrides ...service.ServiceOptions) *service. t.Fatalf("create workflow_step_participants: %v", err) } // Stub of task_sessions, mirroring the columns GetSessionAgentProfileID - // (RunnerProjection's fallback source, see prompt_usage_cost.go) reads. + // (RunnerProjection's fallback source, see prompt_usage_cost.go) reads, + // plus the AC-10 rollup columns so tests can assert the Office + // subscriber never writes them (AC-21). if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS task_sessions ( id TEXT PRIMARY KEY, task_id TEXT NOT NULL, agent_profile_id TEXT, + tokens_in INTEGER NOT NULL DEFAULT 0, + tokens_cached_in INTEGER NOT NULL DEFAULT 0, + tokens_out INTEGER NOT NULL DEFAULT 0, + cost_subcents INTEGER NOT NULL DEFAULT 0, started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )`); err != nil { diff --git a/apps/backend/internal/office/service/event_subscribers.go b/apps/backend/internal/office/service/event_subscribers.go index 73bbd1a995..5029241b5c 100644 --- a/apps/backend/internal/office/service/event_subscribers.go +++ b/apps/backend/internal/office/service/event_subscribers.go @@ -12,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/agent/runtime/routingerr" "github.com/kandev/kandev/internal/agentctl/types/streams" + commoncosts "github.com/kandev/kandev/internal/common/costs" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/office/costs" @@ -701,14 +702,18 @@ func (s *Service) tryPostStartFallback( // // buildCostEvent (prompt_usage_cost.go) also records the cache read/write // split when the usage frame reports cache data, and turn_id / -// usage_event_id threaded from the publish site. The ledger insert and the -// task_sessions rollup increment (tokens_in / tokens_cached_in / tokens_out / -// cost_subcents) are written atomically by recordCostEventAndRollup — see its -// doc comment for why non-atomic writes here are a real defect, not a -// theoretical one — and any applicable budget policy is evaluated afterward. -// Estimated rows count toward budget totals at face value. Every early -// return and the insert path record a writer-health metric (cost_metrics.go) -// so a silently-stopped writer is observable. +// usage_event_id threaded from the publish site. This function only inserts +// the office_cost_events row; it no longer increments the task_sessions +// rollup columns (docs/specs/task-cost-ledger/spec.md AC-21): +// internal/task/usage's writer is now the sole writer of those columns +// (AC-10), inserting task_usage_events and incrementing task_sessions +// atomically in its own transaction (internal/task/repository/sqlite's +// insertUsageEventAndRollup). Before this change, Office incremented the +// same columns here too, so an Office-enabled install (the dev/e2e default) +// double-counted every turn. Any applicable budget policy is evaluated +// after the insert. Estimated rows count toward budget totals at face +// value. Every early return and the insert path record a writer-health +// metric (cost_metrics.go) so a silently-stopped writer is observable. func (s *Service) handlePromptUsage(ctx context.Context, event *bus.Event) error { data, err := decodeEventData[PromptUsageData](event) if err != nil { @@ -748,10 +753,7 @@ func (s *Service) handlePromptUsage(ctx context.Context, event *bus.Event) error *data, fields, s.projectIDForTask(ctx, data.TaskID), provider, resolution, sessionAgentProfileID, ) - if err := s.recordCostEventAndRollup( - ctx, costEvent, data.SessionID, - data.Usage.InputTokens, costEvent.TokensCachedIn, data.Usage.OutputTokens, resolution.costSubcents, - ); err != nil { + if err := s.repo.CreateCostEvent(ctx, costEvent); err != nil { if errors.Is(err, sqlite.ErrDuplicateUsageEvent) { s.recordCostEventDropped(costDropReasonDuplicate, data.TaskID) return nil @@ -772,61 +774,6 @@ func (s *Service) handlePromptUsage(ctx context.Context, event *bus.Event) error return nil } -// recordCostEventAndRollup inserts costEvent and increments the -// task_sessions rollup atomically when the wired sessionUsageWriter -// supports it (shared.SessionUsageWriterTx) — every production wiring does -// (backendapp's SetSessionUsageWriter(repos.Task)). -// -// Atomicity matters because of how this card's own usage_event_id -// idempotency guard interacts with a partial failure: without a shared -// transaction, a rollup-increment failure after a successful ledger insert -// was logged and swallowed, and a later redelivery of the same completion -// (see ErrDuplicateUsageEvent's doc comment for why redelivery is real, not -// hypothetical) would hit the unique index and be dropped as a duplicate -// before the rollup ever got a second chance — task_sessions permanently -// behind office_cost_events with no recovery path (PR #2606 review). Wrapping -// both writes in one transaction closes that: any failure, including the -// rollup increment, rolls back the ledger insert too, so nothing is -// committed and a redelivered completion with the same usage_event_id -// retries the whole operation cleanly instead of colliding with a -// half-landed row. -// -// Falls back to the pre-existing non-atomic two-step write when the wired -// writer doesn't implement SessionUsageWriterTx (e.g. a simplified test -// double that doesn't need transactional coupling, or sessionUsageWriter -// left unset). -func (s *Service) recordCostEventAndRollup( - ctx context.Context, costEvent *models.CostEvent, sessionID string, - tokensIn, tokensCachedIn, tokensOut, costSubcents int64, -) error { - txWriter, ok := s.sessionUsageWriter.(shared.SessionUsageWriterTx) - if !ok { - if err := s.repo.CreateCostEvent(ctx, costEvent); err != nil { - return err - } - s.incrementSessionUsageTotals(ctx, sessionID, tokensIn, tokensCachedIn, tokensOut, costSubcents) - return nil - } - - tx, err := s.repo.BeginTx(ctx) - if err != nil { - return err - } - if err := s.repo.CreateCostEventTx(ctx, tx, costEvent); err != nil { - _ = tx.Rollback() - return err - } - if err := txWriter.IncrementTaskSessionUsageTx( - ctx, tx, sessionID, tokensIn, tokensCachedIn, tokensOut, costSubcents, - ); err != nil { - _ = tx.Rollback() - s.logger.Warn("increment task_session usage failed, rolled back cost event insert", - zap.String("session_id", sessionID), zap.Error(err)) - return err - } - return tx.Commit() -} - // resolveProvider derives the provider id for the cost row. AgentType // (the CLI engine slug — claude-acp, codex-acp, ...) is the most reliable // source; AgentID is checked as a fallback for legacy bus events that @@ -848,31 +795,11 @@ func resolveProvider(data PromptUsageData) string { // providerFromCLI maps the upstream CLI id (the agent_id stream field) // to a provider name. Used because claude-acp emits logical model -// aliases (sonnet / haiku) that can't be matched on prefix. +// aliases (sonnet / haiku) that can't be matched on prefix. Delegates to +// internal/common/costs, the single source of truth shared with the task +// usage ledger writer (docs/specs/task-cost-ledger/spec.md). func providerFromCLI(cli string) string { - switch cli { - case "claude-acp": - return "anthropic" - case "codex-acp", "openai-acp": - return "openai" - case "gemini", "gemini-acp": - return "google" - } - return "" -} - -func (s *Service) incrementSessionUsageTotals( - ctx context.Context, sessionID string, tokensIn, tokensCachedIn, tokensOut, costSubcents int64, -) { - if s.sessionUsageWriter == nil || sessionID == "" { - return - } - if err := s.sessionUsageWriter.IncrementTaskSessionUsage( - ctx, sessionID, tokensIn, tokensCachedIn, tokensOut, costSubcents, - ); err != nil { - s.logger.Warn("increment task_session usage failed", - zap.String("session_id", sessionID), zap.Error(err)) - } + return commoncosts.ProviderFromCLI(cli) } func (s *Service) projectIDForTask(ctx context.Context, taskID string) string { diff --git a/apps/backend/internal/office/service/event_subscribers_cost_atomicity_test.go b/apps/backend/internal/office/service/event_subscribers_cost_atomicity_test.go deleted file mode 100644 index 1bd48c92d1..0000000000 --- a/apps/backend/internal/office/service/event_subscribers_cost_atomicity_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package service_test - -import ( - "context" - "errors" - "sync" - "testing" - - "github.com/jmoiron/sqlx" - - "github.com/kandev/kandev/internal/events" - "github.com/kandev/kandev/internal/events/bus" -) - -// errInjectedRollupFailure is returned by txSessionUsageWriter while armed, -// standing in for a real rollup-side failure (e.g. a constraint violation or -// a dropped connection) that happens after the cost-event insert has -// already been staged in the same transaction. -var errInjectedRollupFailure = errors.New("injected rollup failure") - -// txSessionUsageWriter is a shared.SessionUsageWriterTx test double whose -// Tx-aware increment can be armed to fail a fixed number of times before -// succeeding. It exists to prove recordCostEventAndRollup -// (event_subscribers.go) rolls the cost-event insert back when the rollup -// increment fails inside the shared transaction, and that a redelivery of -// the same usage_event_id afterward is not treated as a duplicate — the -// recovery path the PR #2606 review asked to see covered, since neither -// half of the write had actually committed. -type txSessionUsageWriter struct { - mu sync.Mutex - failNextN int - calls int - - tokensIn int64 - tokensCachedIn int64 - tokensOut int64 - costSubcents int64 -} - -func (w *txSessionUsageWriter) IncrementTaskSessionUsage( - ctx context.Context, sessionID string, tokensIn, tokensCachedIn, tokensOut, costSubcents int64, -) error { - return w.IncrementTaskSessionUsageTx(ctx, nil, sessionID, tokensIn, tokensCachedIn, tokensOut, costSubcents) -} - -func (w *txSessionUsageWriter) IncrementTaskSessionUsageTx( - _ context.Context, _ *sqlx.Tx, _ string, tokensIn, tokensCachedIn, tokensOut, costSubcents int64, -) error { - w.mu.Lock() - defer w.mu.Unlock() - w.calls++ - if w.failNextN > 0 { - w.failNextN-- - return errInjectedRollupFailure - } - w.tokensIn += tokensIn - w.tokensCachedIn += tokensCachedIn - w.tokensOut += tokensOut - w.costSubcents += costSubcents - return nil -} - -// TestPromptUsage_RollupFailureRollsBackLedgerInsertThenRetrySucceeds covers -// the recovery path the PR #2606 review specifically asked for: force the -// rollup update to fail, then retry the same usage_event_id. Before -// recordCostEventAndRollup wrapped both writes in one transaction, the -// ledger insert committed regardless of the rollup outcome; a redelivery -// carrying the same usage_event_id would then hit the unique index and be -// dropped as a duplicate, leaving task_sessions permanently behind -// office_cost_events with no way to catch up. -func TestPromptUsage_RollupFailureRollsBackLedgerInsertThenRetrySucceeds(t *testing.T) { - svc, eb := newTestServiceWithBus(t) - ctx := context.Background() - writer := &txSessionUsageWriter{failNextN: 1} - svc.SetSessionUsageWriter(writer) - - createTestAgent(t, svc, "ws-1", "worker-atomic") - insertTestTask(t, svc, "task-atomic", "ws-1") - setTestTaskAssignee(t, svc, "task-atomic", "worker-atomic") - - buildEvent := func() *bus.Event { - return bus.NewEvent(events.SessionPromptUsageUpdated, "test", map[string]interface{}{ - "task_id": "task-atomic", - "session_id": "session-atomic", - "agent_id": "claude-acp", - "model": "default", - "usage_event_id": "usage-evt-atomic-1", - "usage": map[string]interface{}{ - "input_tokens": 100, - "output_tokens": 50, - }, - }) - } - subject := events.BuildSessionPromptUsageSubject("session-atomic") - - // First delivery: the rollup increment is armed to fail. If the ledger - // insert and the rollup increment were not atomic, the insert would - // already be committed here. - if err := eb.Publish(ctx, subject, buildEvent()); err != nil { - t.Fatalf("publish (failing rollup): %v", err) - } - - costs, err := svc.ListCostEvents(ctx, "ws-1") - if err != nil { - t.Fatalf("list costs after failed rollup: %v", err) - } - if len(costs) != 0 { - t.Fatalf("cost events after failed rollup = %d, want 0 (ledger insert must roll back with the failed rollup)", - len(costs)) - } - if writer.tokensIn != 0 || writer.tokensOut != 0 { - t.Fatalf("rollup totals after failed attempt = (in=%d out=%d), want (0, 0): a failed increment must not partially apply", - writer.tokensIn, writer.tokensOut) - } - - // Redelivery of the same usage_event_id, this time with the rollup - // writer succeeding. Because nothing committed on the first attempt, - // the unique index must not treat this as a duplicate. - if err := eb.Publish(ctx, subject, buildEvent()); err != nil { - t.Fatalf("publish (retry): %v", err) - } - - costs, err = svc.ListCostEvents(ctx, "ws-1") - if err != nil { - t.Fatalf("list costs after retry: %v", err) - } - if len(costs) != 1 { - t.Fatalf("cost events after retry = %d, want 1", len(costs)) - } - if writer.tokensIn != 100 || writer.tokensOut != 50 { - t.Errorf("rollup totals after retry = (in=%d out=%d), want (in=100 out=50)", writer.tokensIn, writer.tokensOut) - } - if writer.calls != 2 { - t.Errorf("rollup writer called %d times, want 2 (one failed attempt, one successful retry)", writer.calls) - } -} diff --git a/apps/backend/internal/office/service/event_subscribers_cost_test.go b/apps/backend/internal/office/service/event_subscribers_cost_test.go index 6d4f337fe4..bd3a5024d2 100644 --- a/apps/backend/internal/office/service/event_subscribers_cost_test.go +++ b/apps/backend/internal/office/service/event_subscribers_cost_test.go @@ -2,6 +2,7 @@ package service_test import ( "context" + "math" "testing" "go.uber.org/zap" @@ -322,6 +323,41 @@ func TestPromptUsage_CostSourceModelsDevList(t *testing.T) { } } +func TestPromptUsage_PricingOverflowFallsBackToUnpriced(t *testing.T) { + svc, eb := newTestServiceWithBus(t) + ctx := context.Background() + svc.SetPricingLookup(&fakePricingLookup{ + hit: true, + pricing: shared.ModelPricing{InputPerMillion: 3}, + }) + + createTestAgent(t, svc, "ws-1", "worker-overflow") + insertTestTask(t, svc, "task-overflow", "ws-1") + setTestTaskAssignee(t, svc, "task-overflow", "worker-overflow") + + event := bus.NewEvent(events.SessionPromptUsageUpdated, "test", map[string]interface{}{ + "task_id": "task-overflow", + "session_id": "session-overflow", + "agent_id": "claude-acp", + "model": "sonnet", + "usage": map[string]interface{}{ + "input_tokens": math.MaxInt64/2 + 1, + }, + }) + if err := eb.Publish(ctx, events.BuildSessionPromptUsageSubject("session-overflow"), event); err != nil { + t.Fatalf("publish: %v", err) + } + + costs, err := svc.ListCostEvents(ctx, "ws-1") + if err != nil || len(costs) != 1 { + t.Fatalf("list costs: %v (len=%d)", err, len(costs)) + } + row := costs[0] + if row.CostSource == nil || *row.CostSource != models.CostSourceUnpriced || row.CostSubcents != 0 { + t.Fatalf("cost = (%v, %d), want (unpriced, 0)", row.CostSource, row.CostSubcents) + } +} + // TestPromptUsage_ThoughtTokensDoNotAffectCost is a regression test for the // triage correction on the card: reasoning_output_tokens is a SUBSET of // output_tokens (OpenAI's own accounting: last_total = last_in + last_out @@ -789,6 +825,50 @@ func TestPromptUsage_DuplicateUsageEventIDIsIdempotent(t *testing.T) { } } +// TestPromptUsage_DoesNotWriteTaskSessionsRollup pins AC-21: the Office cost +// subscriber inserts its own office_cost_events row but SHALL NOT touch +// task_sessions' tokens_in/tokens_cached_in/tokens_out/cost_subcents rollup +// columns — internal/task/usage's writer is the sole writer of those columns +// (AC-10). The session row is seeded with distinctive, nonzero rollup values +// before the event is published; if handlePromptUsage still incremented (or +// otherwise touched) those columns, they would change. +func TestPromptUsage_DoesNotWriteTaskSessionsRollup(t *testing.T) { + svc, eb := newTestServiceWithBus(t) + ctx := context.Background() + + createTestAgent(t, svc, "ws-1", "worker-rollup") + insertTestTask(t, svc, "task-rollup", "ws-1") + setTestTaskAssignee(t, svc, "task-rollup", "worker-rollup") + insertTestTaskSession(t, svc, "session-rollup", "task-rollup", "worker-rollup") + svc.ExecSQL(t, + `UPDATE task_sessions SET tokens_in = ?, tokens_cached_in = ?, tokens_out = ?, cost_subcents = ? WHERE id = ?`, + 111, 22, 33, 44, "session-rollup") + + event := bus.NewEvent(events.SessionPromptUsageUpdated, "test", map[string]interface{}{ + "task_id": "task-rollup", + "session_id": "session-rollup", + "model": "sonnet", + "usage": map[string]interface{}{ + "input_tokens": 100, + "output_tokens": 30, + }, + }) + if err := eb.Publish(ctx, events.BuildSessionPromptUsageSubject("session-rollup"), event); err != nil { + t.Fatalf("publish: %v", err) + } + + costs, err := svc.ListCostEvents(ctx, "ws-1") + if err != nil || len(costs) != 1 { + t.Fatalf("list costs: %v (len=%d) - the handler must still have run and inserted its own row", err, len(costs)) + } + + tokensIn, tokensCachedIn, tokensOut, costSubcents := svc.GetTaskSessionRollupForTest(t, "session-rollup") + if tokensIn != 111 || tokensCachedIn != 22 || tokensOut != 33 || costSubcents != 44 { + t.Errorf("task_sessions rollup = (%d,%d,%d,%d), want unchanged (111,22,33,44) - Office must not write these columns", + tokensIn, tokensCachedIn, tokensOut, costSubcents) + } +} + // TestPromptUsage_BudgetCheckUsesSessionFallbackProfile is a regression test // for the companion budget-check gap: buildCostEvent already resolves the // session fallback into costEvent.AgentProfileID (see diff --git a/apps/backend/internal/office/service/prompt_usage_cost.go b/apps/backend/internal/office/service/prompt_usage_cost.go index 7459554aeb..bd9a558f0e 100644 --- a/apps/backend/internal/office/service/prompt_usage_cost.go +++ b/apps/backend/internal/office/service/prompt_usage_cost.go @@ -49,7 +49,7 @@ func (s *Service) resolveCostForUsage(ctx context.Context, data PromptUsageData) if !ok { return costResolution{estimated: data.Usage.Estimated, source: models.CostSourceUnpriced} } - cost := costs.CalculateCostSubcents( + cost, ok := costs.CalculateCostSubcentsChecked( data.Usage.InputTokens, data.Usage.CachedReadTokens, data.Usage.CachedWriteTokens, @@ -61,6 +61,9 @@ func (s *Service) resolveCostForUsage(ctx context.Context, data PromptUsageData) OutputPerMillion: pricing.OutputPerMillion, }, ) + if !ok { + return costResolution{estimated: data.Usage.Estimated, source: models.CostSourceUnpriced} + } return costResolution{ costSubcents: cost, estimated: data.Usage.Estimated, diff --git a/apps/backend/internal/office/service/service.go b/apps/backend/internal/office/service/service.go index 94b4d4bce3..2fcd6fa68b 100644 --- a/apps/backend/internal/office/service/service.go +++ b/apps/backend/internal/office/service/service.go @@ -256,10 +256,6 @@ type Service struct { // misses and rows get cost_subcents=0 + estimated=true. pricingLookup shared.PricingLookup - // sessionUsageWriter accumulates tokens / cost onto task_sessions - // when a cost event lands. Optional in tests. - sessionUsageWriter shared.SessionUsageWriter - // budgetChecker evaluates budget policies. Wired to the // costs.CostService at startup; nil in tests that don't exercise // the budget pathways. Both CheckBudget and CheckPreExecutionBudget @@ -287,9 +283,6 @@ func (s *Service) SetBudgetChecker(b BudgetEvaluator) { s.budgetChecker = b } // SetPricingLookup wires the models.dev pricing lookup. func (s *Service) SetPricingLookup(p shared.PricingLookup) { s.pricingLookup = p } -// SetSessionUsageWriter wires the task-session usage incrementer. -func (s *Service) SetSessionUsageWriter(w shared.SessionUsageWriter) { s.sessionUsageWriter = w } - // SetAgentTokenMinter wires the runtime token minter after feature services are constructed. func (s *Service) SetAgentTokenMinter(minter AgentTokenMinter) { s.agentTokenMinter = minter diff --git a/apps/backend/internal/office/service/session_usage_reconcile_test.go b/apps/backend/internal/office/service/session_usage_reconcile_test.go deleted file mode 100644 index c0e3b3db0e..0000000000 --- a/apps/backend/internal/office/service/session_usage_reconcile_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package service_test - -import ( - "context" - "testing" - - "github.com/kandev/kandev/internal/events" - "github.com/kandev/kandev/internal/events/bus" -) - -// fakeSessionUsageWriter accumulates the deltas handlePromptUsage sends to -// the task-session rollup, mirroring what the real SQLite repo's -// IncrementTaskSessionUsage does with UPDATE ... SET x = x + ?. -type fakeSessionUsageWriter struct { - tokensIn int64 - tokensCachedIn int64 - tokensOut int64 - costSubcents int64 -} - -func (f *fakeSessionUsageWriter) IncrementTaskSessionUsage( - _ context.Context, _ string, tokensIn, tokensCachedIn, tokensOut, costSubcents int64, -) error { - f.tokensIn += tokensIn - f.tokensCachedIn += tokensCachedIn - f.tokensOut += tokensOut - f.costSubcents += costSubcents - return nil -} - -// TestPromptUsage_RollupReconcilesWithCostLedger asserts the actual -// invariant this rollup exists to maintain: task_sessions' cumulative -// totals must equal the sum of the office_cost_events rows they were -// derived from. Before the fix, tokensCachedIn silently never reaches the -// writer even though every event row carries it, which is exactly what let -// task_sessions.tokens_in under-report cache-heavy sessions by orders of -// magnitude in production. -func TestPromptUsage_RollupReconcilesWithCostLedger(t *testing.T) { - svc, eb := newTestServiceWithBus(t) - ctx := context.Background() - writer := &fakeSessionUsageWriter{} - svc.SetSessionUsageWriter(writer) - - createTestAgent(t, svc, "ws-1", "worker-reconcile") - svc.ExecSQL(t, `INSERT INTO tasks ( - id, workspace_id, project_id, title, created_at, updated_at - ) VALUES ( - 'task-reconcile', 'ws-1', 'project-1', 'Reconcile task', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP - )`) - setTestTaskAssignee(t, svc, "task-reconcile", "worker-reconcile") - - const sessionID = "session-reconcile-1" - - // Cache-heavy shape mirroring the reported bug: cached read tokens - // dominate input tokens by several orders of magnitude. - usages := []map[string]interface{}{ - { - "input_tokens": 100, - "cached_read_tokens": 50_000_000, - "cached_write_tokens": 1_000_000, - "output_tokens": 200, - }, - { - "input_tokens": 10, - "cached_read_tokens": 2_000, - "cached_write_tokens": 0, - "output_tokens": 5, - }, - } - for _, usage := range usages { - event := bus.NewEvent(events.SessionPromptUsageUpdated, "test", map[string]interface{}{ - "task_id": "task-reconcile", - "session_id": sessionID, - "agent_id": "claude-acp", - "model": "default", - "usage": usage, - }) - if err := eb.Publish(ctx, events.BuildSessionPromptUsageSubject(sessionID), event); err != nil { - t.Fatalf("publish prompt usage: %v", err) - } - } - - costs, err := svc.ListCostEvents(ctx, "ws-1") - if err != nil { - t.Fatalf("list costs: %v", err) - } - - var wantIn, wantCachedIn, wantOut, wantCost int64 - var ledgerRows int - for _, c := range costs { - if c.SessionID != sessionID { - continue - } - ledgerRows++ - wantIn += c.TokensIn - wantCachedIn += c.TokensCachedIn - if c.TokensOut != nil { - wantOut += *c.TokensOut - } - wantCost += c.CostSubcents - } - if ledgerRows != len(usages) { - t.Fatalf("ledger rows for session = %d, want %d", ledgerRows, len(usages)) - } - if wantCachedIn == 0 { - t.Fatal("test setup bug: ledger recorded zero cached tokens, reconciliation would be vacuous") - } - - if writer.tokensIn != wantIn { - t.Errorf("rollup tokens_in = %d, want %d (sum of office_cost_events.tokens_in)", writer.tokensIn, wantIn) - } - if writer.tokensCachedIn != wantCachedIn { - t.Errorf("rollup tokens_cached_in = %d, want %d (sum of office_cost_events.tokens_cached_in)", - writer.tokensCachedIn, wantCachedIn) - } - if writer.tokensOut != wantOut { - t.Errorf("rollup tokens_out = %d, want %d (sum of office_cost_events.tokens_out)", writer.tokensOut, wantOut) - } - if writer.costSubcents != wantCost { - t.Errorf("rollup cost_subcents = %d, want %d (sum of office_cost_events.cost_subcents)", writer.costSubcents, wantCost) - } -} diff --git a/apps/backend/internal/office/service/test_helpers.go b/apps/backend/internal/office/service/test_helpers.go index d8e52270be..1a741b8a50 100644 --- a/apps/backend/internal/office/service/test_helpers.go +++ b/apps/backend/internal/office/service/test_helpers.go @@ -67,6 +67,20 @@ func (s *Service) ExecSQL(t *testing.T, query string, args ...interface{}) { } } +// GetTaskSessionRollupForTest reads task_sessions' AC-10 rollup columns +// directly, so tests can assert the Office cost subscriber never writes them +// (docs/specs/task-cost-ledger/spec.md AC-21). +func (s *Service) GetTaskSessionRollupForTest(t *testing.T, sessionID string) (tokensIn, tokensCachedIn, tokensOut, costSubcents int64) { + t.Helper() + if err := s.repo.ReaderDB().QueryRowx( + `SELECT tokens_in, tokens_cached_in, tokens_out, cost_subcents FROM task_sessions WHERE id = ?`, + sessionID, + ).Scan(&tokensIn, &tokensCachedIn, &tokensOut, &costSubcents); err != nil { + t.Fatalf("read task_sessions rollup: %v", err) + } + return +} + // GetWorkspaceGroupForTest exposes workspace-group rows for deletion-order tests. func (s *Service) GetWorkspaceGroupForTest(ctx context.Context, id string) (*models.WorkspaceGroup, error) { return s.repo.GetWorkspaceGroup(ctx, id) diff --git a/apps/backend/internal/office/shared/interfaces.go b/apps/backend/internal/office/shared/interfaces.go index 7936e5a373..f99aeb6cf1 100644 --- a/apps/backend/internal/office/shared/interfaces.go +++ b/apps/backend/internal/office/shared/interfaces.go @@ -5,8 +5,6 @@ import ( "errors" "time" - "github.com/jmoiron/sqlx" - "github.com/kandev/kandev/internal/office/models" "github.com/kandev/kandev/internal/workflow/engine" ) @@ -184,29 +182,3 @@ type PricingLookupWithVersion interface { // catalogue states. LookupForModelWithVersion(ctx context.Context, modelID string) (pricing ModelPricing, version string, ok bool) } - -// SessionUsageWriter increments the cumulative tokens/cost columns on -// task_sessions when a cost event lands. Implemented by the task repo. -type SessionUsageWriter interface { - IncrementTaskSessionUsage(ctx context.Context, sessionID string, - tokensIn, tokensCachedIn, tokensOut, costSubcents int64) error -} - -// SessionUsageWriterTx is an optional capability a SessionUsageWriter -// implementation may satisfy to run the increment inside a caller-supplied -// transaction, so it can be made atomic with the office_cost_events insert -// that produced the deltas (docs/specs/office/costs.md, PR #2606 review): -// without this, a rollup-increment failure after a successful cost-event -// insert is logged and swallowed, and any later redelivery of the same -// completion is caught by the usage_event_id unique index and dropped as a -// duplicate before the rollup gets another chance — a permanent desync with -// no recovery path. Callers type-assert and fall back to the plain -// (non-atomic) SessionUsageWriter method when unavailable — e.g. a -// simplified test double that doesn't need transactional coupling. Every -// production wiring (backendapp's SetSessionUsageWriter(repos.Task)) -// satisfies it, since office_cost_events and task_sessions share one -// underlying *sqlx.DB (see internal/backendapp/storage.go). -type SessionUsageWriterTx interface { - IncrementTaskSessionUsageTx(ctx context.Context, tx *sqlx.Tx, sessionID string, - tokensIn, tokensCachedIn, tokensOut, costSubcents int64) error -} diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go index 9c3774d2f3..acdbb79fdf 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go @@ -1785,6 +1785,58 @@ func TestPublishPromptUsage_RepublishedCompletionReusesUsageEventID(t *testing.T "republishing the same completion must reuse the same usage_event_id so the DB unique index catches the duplicate") } +// TestPublishPromptUsage_RepublishedCompletionWithoutPromptGenerationMintsDistinctUsageEventIDs +// is the integration-level counterpart to TestUsageEventIDFor's unit-level +// promptGeneration==0 assertion, and the random-identifier-class counterpart +// to TestPublishPromptUsage_RepublishedCompletionReusesUsageEventID's +// deterministic-class coverage (docs/specs/task-cost-ledger/spec.md AC-22). +// A generation-less transport's "same" redelivered completion frame mints a +// fresh random usage_event_id on every publish, so the two publishes carry +// DIFFERENT ids: office/costs already documents that those rows are +// intentionally not deduplicated, and this proves that end to end rather +// than just at usageEventIDFor's own return value. +func TestPublishPromptUsage_RepublishedCompletionWithoutPromptGenerationMintsDistinctUsageEventIDs(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "") + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + eb := &recordingEventBus{} + svc.eventBus = eb + + _, err := svc.turnService.StartTurn(ctx, "s1") + require.NoError(t, err) + svc.markExecutionCompleted("s1", "exec-1") + svc.completeTurnForSession(ctx, "s1") + + frame := func() *lifecycle.AgentStreamEventPayload { + return &lifecycle.AgentStreamEventPayload{ + TaskID: "t1", + SessionID: "s1", + ExecutionID: "exec-1", + Data: &lifecycle.AgentStreamEventData{ + Type: agentEventComplete, + PromptGeneration: 0, + Usage: &streams.PromptUsage{InputTokens: 10, OutputTokens: 5}, + }, + } + } + + // Simulates the same buffered stream frame being delivered twice on a + // generation-less transport - same session and execution, but no + // prompt-generation tracking either time. + svc.handleAgentStreamEvent(ctx, frame()) + svc.handleAgentStreamEvent(ctx, frame()) + + published := findAllPromptUsageEvents(t, eb, "s1") + require.Len(t, published, 2, "expected both republished frames to publish a prompt-usage event") + require.NotEmpty(t, published[0].UsageEventID) + require.NotEmpty(t, published[1].UsageEventID) + require.NotEqual(t, published[0].UsageEventID, published[1].UsageEventID, + "a generation-less transport must mint a fresh id per publish, so this redelivery is recorded as a new row and its rollup applied - not deduplicated") +} + func TestCompleteStreamFromCompletedExecutionSkipsDuplicateOfficeTeardown(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) diff --git a/apps/backend/internal/task/dto/converters_test.go b/apps/backend/internal/task/dto/converters_test.go index c72c0d9a23..5b5b8fc00d 100644 --- a/apps/backend/internal/task/dto/converters_test.go +++ b/apps/backend/internal/task/dto/converters_test.go @@ -155,6 +155,55 @@ func TestFromTaskSession_IncludesAllWorktrees(t *testing.T) { } } +// TestFromTaskSession_CopiesRollupColumns pins docs/specs/task-cost-ledger/ +// spec.md AC-28/AC-29: TaskSessionDTO (the full session detail shape) must +// surface the four usage/cost rollup columns internal/task/usage's writer +// maintains on task_sessions. +func TestFromTaskSession_CopiesRollupColumns(t *testing.T) { + session := &models.TaskSession{ + ID: "session-1", + TaskID: "task-1", + CostSubcents: 79118, + TokensIn: 80, + TokensCachedIn: 8203943, + TokensOut: 44979, + } + + got := FromTaskSession(session) + if got.CostSubcents != 79118 || got.TokensIn != 80 || got.TokensCachedIn != 8203943 || got.TokensOut != 44979 { + t.Fatalf("got rollup fields = %+v, want copied verbatim from session", got) + } +} + +// TestFromTaskSessionSummary_ExcludesRollupColumns pins the deliberate scope +// decision (task-cost-ledger plan #11): the cross-task summary projection is +// NOT widened by this card, only the full TaskSessionDTO is. +func TestFromTaskSessionSummary_ExcludesRollupColumns(t *testing.T) { + session := &models.TaskSession{ + ID: "session-1", + TaskID: "task-1", + CostSubcents: 79118, + TokensIn: 80, + TokensCachedIn: 8203943, + TokensOut: 44979, + } + + got := FromTaskSessionSummary(session) + raw, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded map[string]interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, key := range []string{"cost_subcents", "tokens_in", "tokens_cached_in", "tokens_out"} { + if _, ok := decoded[key]; ok { + t.Errorf("TaskSessionSummaryDTO JSON contains %q, want it absent", key) + } + } +} + func TestFromTask_DerivesInterruptedFromMetadata(t *testing.T) { now := time.Now().UTC() diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 9603badf9e..0760656f71 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -356,6 +356,13 @@ type TaskSessionDTO struct { // the newest message the frontend has marked as read. Used by the // transcript to position the unread ("New") divider. LastReadMessageID string `json:"last_read_message_id,omitempty"` + // Usage/cost rollup (docs/specs/task-cost-ledger/spec.md AC-28, AC-29). + // Deliberately not on TaskSessionSummaryDTO - the summary projection used + // by cross-task views is not widened by this card. + CostSubcents int64 `json:"cost_subcents"` + TokensIn int64 `json:"tokens_in"` + TokensCachedIn int64 `json:"tokens_cached_in"` + TokensOut int64 `json:"tokens_out"` } // TaskSessionSummaryDTO is a lightweight version of TaskSessionDTO without snapshot fields. @@ -923,6 +930,10 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO { ReviewStatus: session.ReviewStatus, TaskEnvironmentID: session.TaskEnvironmentID, LastReadMessageID: session.LastReadMessageID, + CostSubcents: session.CostSubcents, + TokensIn: session.TokensIn, + TokensCachedIn: session.TokensCachedIn, + TokensOut: session.TokensOut, } if worktrees := session.WorktreesAPI(); len(worktrees) > 0 { result.WorktreeID = session.Worktrees[0].WorktreeID diff --git a/apps/backend/internal/task/dto/usage_totals.go b/apps/backend/internal/task/dto/usage_totals.go new file mode 100644 index 0000000000..3b9157c339 --- /dev/null +++ b/apps/backend/internal/task/dto/usage_totals.go @@ -0,0 +1,60 @@ +package dto + +import ( + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +// TaskUsageTotalsScope identifies whether a TaskUsageTotalsDTO aggregates an +// entire task or a single session within it (docs/specs/task-cost-ledger/ +// spec.md API surface). +type TaskUsageTotalsScope string + +const ( + TaskUsageTotalsScopeTask TaskUsageTotalsScope = "task" + TaskUsageTotalsScopeSession TaskUsageTotalsScope = "session" +) + +// TaskUsageTotalsDTO is the exact JSON shape both usage-totals HTTP routes +// return (AC-18, AC-19, AC-20). FirstEventAt/LastEventAt serialize to JSON +// null, never omitted, when the scope has no contributing rows. +type TaskUsageTotalsDTO struct { + Scope TaskUsageTotalsScope `json:"scope"` + ScopeID string `json:"scope_id"` + TokensIn int64 `json:"tokens_in"` + TokensCachedRead int64 `json:"tokens_cached_read"` + TokensCachedWrite int64 `json:"tokens_cached_write"` + TokensOut int64 `json:"tokens_out"` + TokensThought int64 `json:"tokens_thought"` + TokensTotal int64 `json:"tokens_total"` + CostSubcents int64 `json:"cost_subcents"` + EventCount int64 `json:"event_count"` + EstimatedEventCount int64 `json:"estimated_event_count"` + UnpricedEventCount int64 `json:"unpriced_event_count"` + OutputTokensComplete bool `json:"output_tokens_complete"` + FirstEventAt *time.Time `json:"first_event_at"` + LastEventAt *time.Time `json:"last_event_at"` +} + +// ToTaskUsageTotalsDTO combines a repository-layer aggregate with the scope +// and scope ID the HTTP route resolved it for. +func ToTaskUsageTotalsDTO(scope TaskUsageTotalsScope, scopeID string, totals *models.TaskUsageTotals) TaskUsageTotalsDTO { + return TaskUsageTotalsDTO{ + Scope: scope, + ScopeID: scopeID, + TokensIn: totals.TokensIn, + TokensCachedRead: totals.TokensCachedRead, + TokensCachedWrite: totals.TokensCachedWrite, + TokensOut: totals.TokensOut, + TokensThought: totals.TokensThought, + TokensTotal: totals.TokensTotal, + CostSubcents: totals.CostSubcents, + EventCount: totals.EventCount, + EstimatedEventCount: totals.EstimatedEventCount, + UnpricedEventCount: totals.UnpricedEventCount, + OutputTokensComplete: totals.OutputTokensComplete, + FirstEventAt: totals.FirstEventAt, + LastEventAt: totals.LastEventAt, + } +} diff --git a/apps/backend/internal/task/dto/usage_totals_test.go b/apps/backend/internal/task/dto/usage_totals_test.go new file mode 100644 index 0000000000..84e12cc912 --- /dev/null +++ b/apps/backend/internal/task/dto/usage_totals_test.go @@ -0,0 +1,93 @@ +package dto + +import ( + "encoding/json" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestToTaskUsageTotalsDTO_ZeroUsage_NullTimestamps(t *testing.T) { + totals := &models.TaskUsageTotals{OutputTokensComplete: true} + + got := ToTaskUsageTotalsDTO(TaskUsageTotalsScopeTask, "task-1", totals) + if got.Scope != TaskUsageTotalsScopeTask || got.ScopeID != "task-1" { + t.Fatalf("scope = (%q, %q), want (task, task-1)", got.Scope, got.ScopeID) + } + if got.EventCount != 0 || got.TokensTotal != 0 { + t.Fatalf("got = %+v, want every sum zero", got) + } + if !got.OutputTokensComplete { + t.Error("OutputTokensComplete = false, want true") + } + + raw, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded map[string]interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + firstEventAt, ok := decoded["first_event_at"] + if !ok { + t.Fatal("first_event_at must be present in the JSON body, not omitted") + } + if firstEventAt != nil { + t.Errorf("first_event_at = %v, want JSON null", firstEventAt) + } + lastEventAt, ok := decoded["last_event_at"] + if !ok { + t.Fatal("last_event_at must be present in the JSON body, not omitted") + } + if lastEventAt != nil { + t.Errorf("last_event_at = %v, want JSON null", lastEventAt) + } +} + +func TestToTaskUsageTotalsDTO_SessionScope_CopiesEveryField(t *testing.T) { + first := time.Date(2026, 8, 23, 4, 0, 0, 0, time.UTC) + last := first.Add(10 * time.Minute) + totals := &models.TaskUsageTotals{ + TokensIn: 80, + TokensCachedRead: 8203943, + TokensCachedWrite: 264177, + TokensOut: 44979, + TokensThought: 0, + TokensTotal: 8513179, + CostSubcents: 79118, + EventCount: 1, + EstimatedEventCount: 0, + UnpricedEventCount: 0, + OutputTokensComplete: true, + FirstEventAt: &first, + LastEventAt: &last, + } + + got := ToTaskUsageTotalsDTO(TaskUsageTotalsScopeSession, "sess-1", totals) + if got.Scope != TaskUsageTotalsScopeSession || got.ScopeID != "sess-1" { + t.Fatalf("scope = (%q, %q), want (session, sess-1)", got.Scope, got.ScopeID) + } + if got.TokensCachedRead != 8203943 || got.TokensTotal != 8513179 || got.CostSubcents != 79118 { + t.Fatalf("got = %+v, want fields copied verbatim from totals", got) + } + if got.FirstEventAt == nil || !got.FirstEventAt.Equal(first) { + t.Errorf("FirstEventAt = %v, want %v", got.FirstEventAt, first) + } + if got.LastEventAt == nil || !got.LastEventAt.Equal(last) { + t.Errorf("LastEventAt = %v, want %v", got.LastEventAt, last) + } + + raw, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded map[string]interface{} + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded["first_event_at"] != "2026-08-23T04:00:00Z" { + t.Errorf("first_event_at = %v, want 2026-08-23T04:00:00Z", decoded["first_event_at"]) + } +} diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 6d6545d733..ff10fb75c2 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -166,6 +166,11 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) { api.POST("/tasks/:id/unarchive", h.httpUnarchiveTask) api.GET("/tasks/:id/subtask-count", h.httpTaskSubtaskCount) + // Task-cost-ledger read surface (docs/specs/task-cost-ledger/spec.md + // AC-18): per-task and per-session usage/cost totals. + api.GET("/tasks/:id/usage", h.httpGetTaskUsageTotals) + api.GET("/tasks/:id/sessions/:sessionId/usage", h.httpGetTaskSessionUsageTotals) + // Task dependencies ("this task is blocked by that one"). Task-scoped // equivalents of the Office-only blocker routes; both go through the single // validator in the task service. diff --git a/apps/backend/internal/task/handlers/task_usage_handlers.go b/apps/backend/internal/task/handlers/task_usage_handlers.go new file mode 100644 index 0000000000..133247e409 --- /dev/null +++ b/apps/backend/internal/task/handlers/task_usage_handlers.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/task/dto" +) + +// httpGetTaskUsageTotals serves GET /api/v1/tasks/:id/usage +// (docs/specs/task-cost-ledger/spec.md AC-18, AC-19, AC-20): the task-scoped +// token/cost aggregate, including rows whose session_id was cleared by +// session deletion. A known task with no usage returns HTTP 200 with zeroed +// totals (AC-20), never an error. +func (h *TaskHandlers) httpGetTaskUsageTotals(c *gin.Context) { + taskID := c.Param("id") + totals, err := h.service.GetTaskUsageTotals(c.Request.Context(), taskID) + if err != nil { + handleNotFound(c, h.logger, err, "task not found") + return + } + c.JSON(http.StatusOK, dto.ToTaskUsageTotalsDTO(dto.TaskUsageTotalsScopeTask, taskID, totals)) +} + +// httpGetTaskSessionUsageTotals serves +// GET /api/v1/tasks/:id/sessions/:sessionId/usage (AC-18, AC-19, AC-20): the +// session-scoped token/cost aggregate. Returns 404 when the task or session +// does not exist, or when the session exists but does not belong to the +// task in the path. +func (h *TaskHandlers) httpGetTaskSessionUsageTotals(c *gin.Context) { + taskID := c.Param("id") + sessionID := c.Param("sessionId") + totals, err := h.service.GetTaskSessionUsageTotals(c.Request.Context(), taskID, sessionID) + if err != nil { + handleNotFound(c, h.logger, err, "task session not found") + return + } + c.JSON(http.StatusOK, dto.ToTaskUsageTotalsDTO(dto.TaskUsageTotalsScopeSession, sessionID, totals)) +} diff --git a/apps/backend/internal/task/handlers/task_usage_handlers_test.go b/apps/backend/internal/task/handlers/task_usage_handlers_test.go new file mode 100644 index 0000000000..f89593fa15 --- /dev/null +++ b/apps/backend/internal/task/handlers/task_usage_handlers_test.go @@ -0,0 +1,153 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/service" +) + +// usageTotalsRepo backs the task-cost-ledger HTTP handler tests +// (docs/specs/task-cost-ledger/spec.md AC-18). It reuses foreignSessionRepo's +// task-b/sess-b-owned-by-user-b fixture and stubs the two aggregate reads. +type usageTotalsRepo struct { + foreignSessionRepo + taskTotals *models.TaskUsageTotals + sessionTotals *models.TaskUsageTotals +} + +func (r *usageTotalsRepo) GetTaskUsageTotals(context.Context, string) (*models.TaskUsageTotals, error) { + return r.taskTotals, nil +} + +func (r *usageTotalsRepo) GetSessionUsageTotals(context.Context, string) (*models.TaskUsageTotals, error) { + return r.sessionTotals, nil +} + +func newUsageTotalsHandlers(t *testing.T, taskTotals, sessionTotals *models.TaskUsageTotals) *TaskHandlers { + t.Helper() + log := newTestLogger(t) + repo := &usageTotalsRepo{taskTotals: taskTotals, sessionTotals: sessionTotals} + svc := service.NewService(service.Repos{ + Workspaces: repo, Tasks: repo, TaskRepos: repo, + Workflows: repo, Messages: repo, Turns: repo, + Sessions: repo, GitSnapshots: repo, RepoEntities: repo, + Executors: repo, Environments: repo, TaskEnvironments: repo, + Reviews: repo, Usage: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + return &TaskHandlers{service: svc, logger: log} +} + +func taskUsageRequestAs(t *testing.T, path, userID string, params gin.Params) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, path, nil) + c.Request = c.Request.WithContext( + authn.WithIdentity(c.Request.Context(), authn.Identity{UserID: userID, Role: authn.RoleMember}), + ) + c.Params = params + return c, rec +} + +// TestHTTPGetTaskUsageTotalsDeniesForeignTaskWith404 pins the scoping +// requirement CLAUDE.md's backend security-boundary rule adds to any new +// task-keyed route: a foreign owner is denied, the real owner passes. +func TestHTTPGetTaskUsageTotalsDeniesForeignTaskWith404(t *testing.T) { + gin.SetMode(gin.TestMode) + h := newUsageTotalsHandlers(t, &models.TaskUsageTotals{OutputTokensComplete: true}, nil) + + c, rec := taskUsageRequestAs(t, "/api/v1/tasks/task-b/usage", "user-a", gin.Params{{Key: "id", Value: "task-b"}}) + h.httpGetTaskUsageTotals(c) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (body: %s)", rec.Code, rec.Body.String()) + } + + ownerCtx, ownerRec := taskUsageRequestAs(t, "/api/v1/tasks/task-b/usage", "user-b", gin.Params{{Key: "id", Value: "task-b"}}) + h.httpGetTaskUsageTotals(ownerCtx) + if ownerRec.Code != http.StatusOK { + t.Fatalf("owner status = %d, want 200 (body: %s)", ownerRec.Code, ownerRec.Body.String()) + } +} + +// TestHTTPGetTaskUsageTotalsZeroUsageReturns200WithZeroedBody pins AC-20: a +// known task with no recorded usage answers 200 with zeroed totals, not an +// error, and the response identifies its own scope. +func TestHTTPGetTaskUsageTotalsZeroUsageReturns200WithZeroedBody(t *testing.T) { + gin.SetMode(gin.TestMode) + h := newUsageTotalsHandlers(t, &models.TaskUsageTotals{OutputTokensComplete: true}, nil) + + c, rec := taskUsageRequestAs(t, "/api/v1/tasks/task-b/usage", "user-b", gin.Params{{Key: "id", Value: "task-b"}}) + h.httpGetTaskUsageTotals(c) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String()) + } + + var body dto.TaskUsageTotalsDTO + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body.Scope != dto.TaskUsageTotalsScopeTask || body.ScopeID != "task-b" { + t.Fatalf("scope = (%q, %q), want (task, task-b)", body.Scope, body.ScopeID) + } + if body.EventCount != 0 { + t.Fatalf("EventCount = %d, want 0", body.EventCount) + } + if !body.OutputTokensComplete { + t.Error("OutputTokensComplete = false, want true for zero usage") + } +} + +// TestHTTPGetTaskSessionUsageTotalsDeniesForeignSessionWith404 mirrors the +// task-scoped denial test for the session-scoped route. +func TestHTTPGetTaskSessionUsageTotalsDeniesForeignSessionWith404(t *testing.T) { + gin.SetMode(gin.TestMode) + h := newUsageTotalsHandlers(t, nil, &models.TaskUsageTotals{OutputTokensComplete: true}) + params := gin.Params{{Key: "id", Value: "task-b"}, {Key: "sessionId", Value: "sess-b"}} + + c, rec := taskUsageRequestAs(t, "/api/v1/tasks/task-b/sessions/sess-b/usage", "user-a", params) + h.httpGetTaskSessionUsageTotals(c) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (body: %s)", rec.Code, rec.Body.String()) + } + + ownerCtx, ownerRec := taskUsageRequestAs(t, "/api/v1/tasks/task-b/sessions/sess-b/usage", "user-b", params) + h.httpGetTaskSessionUsageTotals(ownerCtx) + if ownerRec.Code != http.StatusOK { + t.Fatalf("owner status = %d, want 200 (body: %s)", ownerRec.Code, ownerRec.Body.String()) + } +} + +// TestHTTPGetTaskSessionUsageTotalsReturnsSessionScopedBody proves the +// session route stamps scope=session and the path's sessionId, not the +// task's, into the response. +func TestHTTPGetTaskSessionUsageTotalsReturnsSessionScopedBody(t *testing.T) { + gin.SetMode(gin.TestMode) + h := newUsageTotalsHandlers(t, nil, &models.TaskUsageTotals{EventCount: 3, OutputTokensComplete: true}) + params := gin.Params{{Key: "id", Value: "task-b"}, {Key: "sessionId", Value: "sess-b"}} + + c, rec := taskUsageRequestAs(t, "/api/v1/tasks/task-b/sessions/sess-b/usage", "user-b", params) + h.httpGetTaskSessionUsageTotals(c) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String()) + } + + var body dto.TaskUsageTotalsDTO + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body.Scope != dto.TaskUsageTotalsScopeSession || body.ScopeID != "sess-b" { + t.Fatalf("scope = (%q, %q), want (session, sess-b)", body.Scope, body.ScopeID) + } + if body.EventCount != 3 { + t.Fatalf("EventCount = %d, want 3", body.EventCount) + } +} diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 7f08ca05a1..9d92f7118f 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -1450,6 +1450,15 @@ type TaskSession struct { // panel; the frontend snapshots the PRIOR value at that moment to draw // the divider before overwriting it. LastReadMessageID string `json:"last_read_message_id,omitempty"` + + // Usage/cost rollup columns (docs/specs/task-cost-ledger/spec.md AC-28, + // AC-29). task_usage_events is the source of truth; these are the + // running totals internal/task/usage's writer maintains transactionally + // alongside each ledger insert via IncrementTaskSessionUsageTx. + CostSubcents int64 `json:"cost_subcents"` + TokensIn int64 `json:"tokens_in"` + TokensCachedIn int64 `json:"tokens_cached_in"` + TokensOut int64 `json:"tokens_out"` } // ToAPI converts internal TaskSession to API type diff --git a/apps/backend/internal/task/models/usage_event.go b/apps/backend/internal/task/models/usage_event.go new file mode 100644 index 0000000000..d9b08e5719 --- /dev/null +++ b/apps/backend/internal/task/models/usage_event.go @@ -0,0 +1,42 @@ +package models + +import "time" + +// TaskUsageEvent is one immutable row of internal/task/repository/sqlite's +// task_usage_events ledger (docs/specs/task-cost-ledger/spec.md). A pointer +// token/rate field means the value is *not recorded*, distinct from a +// measured zero (AC-4, AC-30); SessionID, TurnID, and PricingCatalogVersion +// use "" for SQL NULL since they are always either absent or a non-empty +// identifier. +type TaskUsageEvent struct { + ID int64 + UsageEventID string + TaskID string + SessionID string + TurnID string + AgentProfileID string + AgentType string + Model string + Provider string + + TokensIn int64 + TokensCachedRead *int64 + TokensCachedWrite *int64 + TokensOut *int64 + TokensThought *int64 + TokensTotal int64 + + CostSubcents int64 + CostSource string + Estimated bool + + RateInputPerMillion *int64 + RateCachedReadPerMillion *int64 + RateCachedWritePerMillion *int64 + RateOutputPerMillion *int64 + PricingCatalogVersion string + + ContractVersion int + OccurredAt time.Time + CreatedAt time.Time +} diff --git a/apps/backend/internal/task/models/usage_totals.go b/apps/backend/internal/task/models/usage_totals.go new file mode 100644 index 0000000000..511e94c13e --- /dev/null +++ b/apps/backend/internal/task/models/usage_totals.go @@ -0,0 +1,27 @@ +package models + +import "time" + +// TaskUsageTotals is the read-side aggregate over task_usage_events for one +// scope (a task, or a single session within a task) - docs/specs/task-cost-ledger/spec.md +// AC-18, AC-19, AC-20. TokensTotal is the sum of each row's stored +// tokens_total column, never recomputed from the per-kind sums at read time. +// OutputTokensComplete is false when any contributing row has a nil +// TokensOut (AC-12): the total is then a lower bound, not an exact count. A +// scope with EventCount zero reports every sum as zero, +// OutputTokensComplete true, and both timestamps nil (AC-20). +type TaskUsageTotals struct { + TokensIn int64 + TokensCachedRead int64 + TokensCachedWrite int64 + TokensOut int64 + TokensThought int64 + TokensTotal int64 + CostSubcents int64 + EventCount int64 + EstimatedEventCount int64 + UnpricedEventCount int64 + OutputTokensComplete bool + FirstEventAt *time.Time + LastEventAt *time.Time +} diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 5c8539e363..efca47608a 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -588,3 +588,14 @@ type SubagentContextRepository interface { ListSubagentContextsBySession(ctx context.Context, sessionID string) ([]*models.SubagentContext, error) ListSubagentContextsByTurn(ctx context.Context, turnID string) ([]*models.SubagentContext, error) } + +// UsageRepository serves the task-cost-ledger read surface +// (docs/specs/task-cost-ledger/spec.md AC-18, AC-19, AC-20): per-task and +// per-session aggregate totals over task_usage_events. The ledger write path +// (CreateTaskUsageEvent, ListTaskUsageEvents) is deliberately not part of +// this interface - it is consumed only by internal/task/usage's own narrow +// Repository interface, never through the Service layer. +type UsageRepository interface { + GetTaskUsageTotals(ctx context.Context, taskID string) (*models.TaskUsageTotals, error) + GetSessionUsageTotals(ctx context.Context, sessionID string) (*models.TaskUsageTotals, error) +} diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index ada9d13b8b..899e680a23 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -31,6 +31,47 @@ type Repository struct { // cutover: when set to a cutover step name, the migration aborts at that // step so tests can prove rollback restores the pre-upgrade state. failCutoverAfter string + // failUsageEventAttempts/failUsageEventErr are a test-only failpoint for + // CreateTaskUsageEvent's AC-32 transient-retry loop: while + // failUsageEventAttempts > 0, insertUsageEventAndRollup returns + // failUsageEventErr before opening a transaction, without touching the + // database, and decrements the counter. A genuine SQLITE_BUSY/serialization + // race cannot be triggered deterministically against this package's + // single-connection test repositories, so this stands in for one. Use this + // (not the mid-transaction seam below) when a scenario needs the injected + // failure to happen before any real driver-level activity - e.g. before a + // same-attempt foreign-key violation the test also wants to observe. + failUsageEventAttempts int + failUsageEventErr error + // failUsageEventRollupAttempts/failUsageEventRollupErr are a test-only + // failpoint inside insertUsageEventAndRollup's transaction, checked after + // the ledger row insert succeeds but before the task_sessions rollup + // increment: while failUsageEventRollupAttempts > 0, the function returns + // failUsageEventRollupErr and decrements the counter, so the deferred + // tx.Rollback() unwinds a real, already-attempted INSERT. Unlike + // failUsageEventAttempts (which fires before BeginTxx and so cannot tell + // an atomic single-transaction implementation from a hypothetical + // two-transaction one, since neither has touched the database yet), this + // seam proves AC-11's atomicity: an implementation that committed the + // insert in its own transaction before attempting the rollup would leave a + // row behind here, while the real single-transaction implementation rolls + // it back with everything else. + failUsageEventRollupAttempts int + failUsageEventRollupErr error + // usageEventPreRollupHook is a test-only synchronization seam, called (if + // set) inside insertUsageEventAndRollup's transaction at the same point as + // the failUsageEventRollup* failpoint - after the ledger row insert + // succeeds, before the task_sessions rollup increment. Unlike the + // failpoints, it does not alter control flow; it exists so a Postgres test + // can pause a real production transaction at this exact boundary and open + // a second, genuinely concurrent connection to construct a real lock + // conflict there (a real SQLITE_BUSY/serialization race cannot be + // provoked deterministically against SQLite's single test connection, but + // Postgres supports true concurrent connections, so this proves AC-32's + // retry classification against a real driver-reported 40001/40P01 rather + // than only the injected failpoint errors). Nil in production and in + // every test but the one that sets it. + usageEventPreRollupHook func() } func (r *Repository) nowUTC() time.Time { diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index 2d14d207f9..c31add7db9 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -2,7 +2,6 @@ package sqlite import ( - "context" "database/sql" "errors" "fmt" @@ -45,81 +44,11 @@ func (r *Repository) migrateSessionsAddCostColumns() { // session (the reported bug measured up to 98,805,109 on one already- // completed task). SQLite's INTEGER is 64-bit regardless, but on Postgres // INTEGER is int4 - an overflowing session would abort the single - // multi-column UPDATE in IncrementTaskSessionUsage and the single - // table-wide UPDATE in BackfillSessionTokensCachedIn, silently taking + // multi-column UPDATE in IncrementTaskSessionUsage, silently taking // tokens_in/tokens_out/cost_subcents down with it for that session. r.migrate.Apply("task_sessions.tokens_cached_in", `ALTER TABLE task_sessions ADD COLUMN tokens_cached_in BIGINT NOT NULL DEFAULT 0`) } -// BackfillSessionTokensCachedIn recomputes task_sessions.tokens_cached_in from -// the office_cost_events ledger. The rollup is purely derived from the ledger, -// so recomputing from it is restoring data, not inventing it. -// -// Deliberately NOT called from runMigrations(): office_cost_events is owned and -// created by internal/office/repository/sqlite, a separate repository that -// shares this database but initializes independently, and on a fresh boot the -// task repository's own migrations run first (internal/backendapp/storage.go). -// An earlier version of this method guarded on the ledger table's existence to -// tolerate that ordering from inside runMigrations(); that guard, and the table -// existence it checked, both went away in favor of the caller in -// internal/backendapp/storage.go, which only invokes this after office.Provide -// has already succeeded — construction order guarantees office_cost_events -// exists by then, so no guard is needed here. This keeps the task repository -// from having to know office's schema/table-existence details, and lets the -// office_cost_events(session_id) index it depends on live with the table it -// indexes (internal/office/repository/sqlite/base.go createCostTables). -// -// Deliberately unconditional per session it touches (assignment, not -// increment, so it is idempotent across replays) rather than gated on -// "already nonzero": the rollup can go out of sync with the ledger without -// erroring — a live IncrementTaskSessionUsage call is a best-effort -// UPDATE ... WHERE id = ? that silently matches zero rows if the session row -// doesn't exist yet (see the "missing row" case in -// TestIncrementTaskSessionUsage_UnknownSessionNoError), and -// event_subscribers.go only logs a Warn when the rollup write fails while the -// ledger insert has already committed. A "skip if nonzero" guard would make -// exactly that drift permanent instead of self-healing it on the next boot. -// (If the ledger for a session was deleted separately from the session row -// itself - see the two-transaction delete in workspace_deletion.go - this -// recompute correctly zeroes that session's tokens_cached_in, since an empty -// ledger sums to zero; the rollup's only contract is to equal the ledger sum.) -// -// The WHERE clause below is a boot-cost optimization, not a correctness gate: -// it restricts the write to sessions that either have ledger rows (need a -// possible recompute) or already hold a nonzero value (need a possible -// zeroing, per the paragraph above). Every row it excludes has -// tokens_cached_in = 0 AND no ledger rows, so the unconditional -// COALESCE(NULL, 0) = 0 this statement would otherwise compute for it is -// already what the row holds — excluding it is a provable no-op, never a -// skip of a row that needs correcting. This runs unconditionally on every -// boot (no run-once tracking - see MigrateLogger.Apply, which this method -// deliberately does not use so a real failure propagates instead of being -// swallowed as a Warn), so office_cost_events(session_id) must already be -// indexed: an unindexed correlated subquery here is quadratic in -// (sessions x ledger rows), measured at 76.72s at a modest 4,000 sessions / -// 80,000 events versus 0.17s indexed. -// -// Returns the number of task_sessions rows the WHERE clause matched, so a -// caller can log it for boot-time observability; not otherwise used for -// correctness. -func (r *Repository) BackfillSessionTokensCachedIn(ctx context.Context) (int64, error) { - result, err := r.db.ExecContext(ctx, ` - UPDATE task_sessions - SET tokens_cached_in = COALESCE( - (SELECT SUM(e.tokens_cached_in) FROM office_cost_events e - WHERE e.session_id = task_sessions.id), 0) - WHERE EXISTS (SELECT 1 FROM office_cost_events e WHERE e.session_id = task_sessions.id) - OR tokens_cached_in <> 0`) - if err != nil { - return 0, fmt.Errorf("backfill task_sessions.tokens_cached_in: %w", err) - } - affected, err := result.RowsAffected() - if err != nil { - return 0, fmt.Errorf("backfill task_sessions.tokens_cached_in: rows affected: %w", err) - } - return affected, nil -} - // runMigrations applies idempotent ALTER TABLE migrations for schema evolution. func (r *Repository) runMigrations() error { if err := r.migrateTaskPriorityToTextPostgres(); err != nil { @@ -259,10 +188,10 @@ func (r *Repository) runMigrations() error { // task_sessions rebuilds above so it repairs legacy DBs whose schema can no // longer trigger a rebuild (see migrateSessionsAddCostColumns). r.migrateSessionsAddCostColumns() - // BackfillSessionTokensCachedIn is deliberately NOT called here - see its - // doc comment. It runs from internal/backendapp/storage.go, after both - // this repository and the office repository (which owns office_cost_events) - // have finished initializing. + // AC-28: widen the three still-INTEGER rollup columns to BIGINT (Postgres + // only). Must run after migrateSessionsAddCostColumns so a legacy DB has + // the columns to widen before this ALTERs their type. + r.migrateTaskSessionsRollupColumnsToBigint() // Office task extensions - net-new columns on existing main tables. // Idempotent ALTERs; main upgrades pick them up at first boot. diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index d458906f37..7b4e2211e6 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -23,6 +23,7 @@ func (r *Repository) initSchema() error { r.initSessionSchema, r.initDynamicRoutingSchema, r.initStepTransitionsSchema, + r.initTaskUsageEventsSchema, r.initAttachmentsSchema, r.initTaskResourceCleanupSchema, r.initGitSchema, diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 93d6caabbb..d2aca30375 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -475,7 +475,8 @@ const taskSessionSelectCols = `ts.id, ts.task_id, COALESCE(NULLIF(te.workspace_path, ''), ts.workspace_path), ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot, ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at, - ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id, ts.name, ts.last_read_message_id` + ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id, ts.name, ts.last_read_message_id, + ts.cost_subcents, ts.tokens_in, ts.tokens_cached_in, ts.tokens_out` // taskSessionFromClause is the FROM clause that pairs with taskSessionSelectCols. // Always reference task_sessions as `ts` and executors_running as `er` in WHERE/ORDER. @@ -987,6 +988,7 @@ func (r *Repository) scanTaskSession(ctx context.Context, row *sql.Row, noRowsEr &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, &isPrimary, &reviewStatus, &isPassthrough, &session.TaskEnvironmentID, &name, &lastReadMessageID, + &session.CostSubcents, &session.TokensIn, &session.TokensCachedIn, &session.TokensOut, ) if err == sql.ErrNoRows { @@ -2142,29 +2144,16 @@ func (r *Repository) GetLastAgentMessage(ctx context.Context, sessionID string) return content, nil } -// IncrementTaskSessionUsage adds the given deltas to the cumulative +// IncrementTaskSessionUsageTx adds the given deltas to the cumulative // tokens / cost columns on task_sessions, including cached input tokens // (tokens_cached_in mirrors office_cost_events.tokens_cached_in and is kept -// separate from tokens_in because it is priced differently). Used by the -// office cost subscriber after a cost event lands so the per-session totals -// stay in sync without re-summing office_cost_events. The model + DTO -// don't surface these columns yet (DB-only per the office-costs -// wedge); the cost explorer follow-up will expose them. -// -// Delegates to IncrementTaskSessionUsageTx using r.db as the executor; a -// caller that needs this atomic with another write (e.g. the office cost -// subscriber's ledger insert) should call the Tx variant directly with a -// shared transaction instead. -func (r *Repository) IncrementTaskSessionUsage( - ctx context.Context, sessionID string, tokensIn, tokensCachedIn, tokensOut, costSubcents int64, -) error { - return r.IncrementTaskSessionUsageTx(ctx, nil, sessionID, tokensIn, tokensCachedIn, tokensOut, costSubcents) -} - -// IncrementTaskSessionUsageTx implements shared.SessionUsageWriterTx: same -// write as IncrementTaskSessionUsage, but executed against tx when non-nil -// (falling back to r.db, the shared writer connection, when tx is nil) so a -// caller can make this atomic with another write in the same transaction. +// separate from tokens_in because it is priced differently). Surfaced on +// models.TaskSession and dto.TaskSessionDTO. internal/task/usage's writer is +// the sole production caller (docs/specs/task-cost-ledger/spec.md AC-10, +// AC-21) — it inserts a task_usage_events row and increments this rollup in +// one transaction (insertUsageEventAndRollup in this package), so this +// method is executed against tx when non-nil (falling back to r.db, the +// shared writer connection, when tx is nil). func (r *Repository) IncrementTaskSessionUsageTx( ctx context.Context, tx *sqlx.Tx, sessionID string, tokensIn, tokensCachedIn, tokensOut, costSubcents int64, ) error { @@ -2621,6 +2610,7 @@ func scanTaskSessionRow(rows *sql.Rows) (*models.TaskSession, error) { &agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON, &state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt, &isPrimary, &reviewStatus, &isPassthrough, &session.TaskEnvironmentID, &name, &lastReadMessageID, + &session.CostSubcents, &session.TokensIn, &session.TokensCachedIn, &session.TokensOut, ) if err != nil { return nil, err diff --git a/apps/backend/internal/task/repository/sqlite/session_test.go b/apps/backend/internal/task/repository/sqlite/session_test.go index b1b2d30d51..fe63c0eaba 100644 --- a/apps/backend/internal/task/repository/sqlite/session_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_test.go @@ -1101,10 +1101,10 @@ func TestIncrementTaskSessionUsage_AccumulatesAcrossCalls(t *testing.T) { ctx := context.Background() seedForMsgTest(t, repo, "task-usage", "sess-usage", "turn-usage") - if err := repo.IncrementTaskSessionUsage(ctx, "sess-usage", 100, 0, 200, 50); err != nil { + if err := repo.IncrementTaskSessionUsageTx(ctx, nil, "sess-usage", 100, 0, 200, 50); err != nil { t.Fatalf("first increment: %v", err) } - if err := repo.IncrementTaskSessionUsage(ctx, "sess-usage", 10, 0, 20, 5); err != nil { + if err := repo.IncrementTaskSessionUsageTx(ctx, nil, "sess-usage", 10, 0, 20, 5); err != nil { t.Fatalf("second increment: %v", err) } @@ -1124,7 +1124,7 @@ func TestIncrementTaskSessionUsage_AccumulatesAcrossCalls(t *testing.T) { // missing row (subscriber may race against session creation). func TestIncrementTaskSessionUsage_UnknownSessionNoError(t *testing.T) { repo := newRepoForSessionTests(t) - if err := repo.IncrementTaskSessionUsage(context.Background(), "no-such", 1, 1, 2, 3); err != nil { + if err := repo.IncrementTaskSessionUsageTx(context.Background(), nil, "no-such", 1, 1, 2, 3); err != nil { t.Errorf("expected no error for unknown session, got %v", err) } } @@ -1133,7 +1133,7 @@ func TestIncrementTaskSessionUsage_UnknownSessionNoError(t *testing.T) { // orchestrator publishing a usage event before SessionID is set. func TestIncrementTaskSessionUsage_EmptySessionIDNoOp(t *testing.T) { repo := newRepoForSessionTests(t) - if err := repo.IncrementTaskSessionUsage(context.Background(), "", 1, 1, 2, 3); err != nil { + if err := repo.IncrementTaskSessionUsageTx(context.Background(), nil, "", 1, 1, 2, 3); err != nil { t.Errorf("empty session id should be a no-op, got %v", err) } } @@ -1189,7 +1189,7 @@ func rebuildSessionsWithoutCostColumns(t *testing.T, repo *Repository) { // TestMigrateSessionsAddCostColumns_BackfillsLegacySchema reproduces the office // cost subscriber failure ("no such column: tokens_in"): a task_sessions table // that predates the cost columns and no longer contains the rebuild trigger -// columns can never gain them, so IncrementTaskSessionUsage fails. The additive +// columns can never gain them, so IncrementTaskSessionUsageTx fails. The additive // migration must backfill the columns idempotently. func TestMigrateSessionsAddCostColumns_BackfillsLegacySchema(t *testing.T) { repo := newRepoForSessionTests(t) @@ -1199,20 +1199,20 @@ func TestMigrateSessionsAddCostColumns_BackfillsLegacySchema(t *testing.T) { seedForMsgTest(t, repo, "task-mig", "sess-mig", "turn-mig") // Precondition: this is the reported bug on a legacy schema. - if err := repo.IncrementTaskSessionUsage(ctx, "sess-mig", 1, 1, 2, 3); err == nil { + if err := repo.IncrementTaskSessionUsageTx(ctx, nil, "sess-mig", 1, 1, 2, 3); err == nil { t.Fatal("expected missing-column error before backfill") } repo.migrateSessionsAddCostColumns() - if err := repo.IncrementTaskSessionUsage(ctx, "sess-mig", 1, 1, 2, 3); err != nil { - t.Fatalf("IncrementTaskSessionUsage after backfill: %v", err) + if err := repo.IncrementTaskSessionUsageTx(ctx, nil, "sess-mig", 1, 1, 2, 3); err != nil { + t.Fatalf("IncrementTaskSessionUsageTx after backfill: %v", err) } // Idempotent: a second pass over a table that already has the columns is a no-op. repo.migrateSessionsAddCostColumns() - if err := repo.IncrementTaskSessionUsage(ctx, "sess-mig", 10, 10, 20, 30); err != nil { - t.Fatalf("IncrementTaskSessionUsage after second pass: %v", err) + if err := repo.IncrementTaskSessionUsageTx(ctx, nil, "sess-mig", 10, 10, 20, 30); err != nil { + t.Fatalf("IncrementTaskSessionUsageTx after second pass: %v", err) } } diff --git a/apps/backend/internal/task/repository/sqlite/session_usage_postgres_test.go b/apps/backend/internal/task/repository/sqlite/session_usage_postgres_test.go index bc79c631f7..336df3e2cb 100644 --- a/apps/backend/internal/task/repository/sqlite/session_usage_postgres_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_usage_postgres_test.go @@ -2,22 +2,20 @@ package sqlite import ( "context" - "fmt" "testing" "time" "github.com/kandev/kandev/internal/testutil" ) -// The cached-token rollup (BackfillSessionTokensCachedIn, -// IncrementTaskSessionUsage) is dialect-sensitive in a way the SQLite tests in -// session_usage_test.go and session_test.go cannot exercise: SQLite's INTEGER -// is 64-bit, so a column declared INTEGER never overflows there. On Postgres, -// INTEGER is int4 (max 2,147,483,647), and office_cost_events.tokens_cached_in -// routinely accumulates well past that for a single long-running session (the -// reported bug measured up to 98,805,109 on one already-completed task). -// Without this file the type of the new column was never exercised against -// Postgres at all. +// The cached-token rollup (IncrementTaskSessionUsageTx) is dialect-sensitive in +// a way the SQLite tests in session_usage_test.go and session_test.go cannot +// exercise: SQLite's INTEGER is 64-bit, so a column declared INTEGER never +// overflows there. On Postgres, INTEGER is int4 (max 2,147,483,647), and +// office_cost_events.tokens_cached_in routinely accumulates well past that +// for a single long-running session (the reported bug measured up to +// 98,805,109 on one already-completed task). Without this file the type of +// the new column was never exercised against Postgres at all. // // Skips unless KANDEV_TEST_POSTGRES_DSN is set. @@ -38,98 +36,8 @@ func seedPostgresTaskSession(t *testing.T, repo *Repository, taskID, sessionID s } } -// TestPostgresBackfillSessionTokensCachedIn_SumsLedger is the Postgres -// counterpart to TestBackfillSessionTokensCachedIn_SumsLedger: proves the -// correlated-subquery UPDATE and its CREATE INDEX IF NOT EXISTS both work -// against a real Postgres instance, not just SQLite. -func TestPostgresBackfillSessionTokensCachedIn_SumsLedger(t *testing.T) { - db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) - repo, err := NewWithDB(db, db, nil) - if err != nil { - t.Fatalf("init postgres schema: %v", err) - } - seedPostgresTaskSession(t, repo, "task-backfill-pg", "sess-backfill-pg") - createOfficeCostEventsTable(t, repo) - insertOfficeCostEvent(t, repo, "cost-1", "sess-backfill-pg", 98_805_109) - insertOfficeCostEvent(t, repo, "cost-2", "sess-backfill-pg", 1_194_891) - insertOfficeCostEvent(t, repo, "cost-other", "sess-other-pg", 555) - - if _, err := repo.BackfillSessionTokensCachedIn(context.Background()); err != nil { - t.Fatalf("backfill: %v", err) - } - - var got int64 - if err := repo.ro.QueryRowx(repo.ro.Rebind( - `SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-backfill-pg", - ).Scan(&got); err != nil { - t.Fatalf("read row: %v", err) - } - if got != 100_000_000 { - t.Errorf("tokens_cached_in = %d, want 100000000 (sum of office_cost_events for this session)", got) - } -} - -// TestPostgresBackfillSessionTokensCachedIn_HandlesValuesBeyondInt32Range is -// the regression test for F8: task_sessions.tokens_cached_in was declared -// INTEGER (int4 on Postgres, max 2,147,483,647). Because the backfill is a -// single UPDATE across the whole task_sessions table, one session whose -// ledger sum exceeds the int4 ceiling made the entire statement fail with -// "integer out of range" — swallowed to a Warn by MigrateLogger.Apply, so the -// self-healing recompute silently stopped working for every session, not just -// the overflowing one. Fails on an INTEGER column, passes once the column is -// BIGINT. -func TestPostgresBackfillSessionTokensCachedIn_HandlesValuesBeyondInt32Range(t *testing.T) { - db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) - repo, err := NewWithDB(db, db, nil) - if err != nil { - t.Fatalf("init postgres schema: %v", err) - } - seedPostgresTaskSession(t, repo, "task-overflow-pg", "sess-overflow-pg") - seedPostgresTaskSession(t, repo, "task-normal-pg", "sess-normal-pg") - createOfficeCostEventsTable(t, repo) - - // 200 events of 14,000,000 cached tokens each = 2,800,000,000, well past - // the int32 ceiling of 2,147,483,647 - the shape the card's own live-instance - // measurement (98,805,109 on a single completed task) extrapolates to over - // a long-running session. - const perEvent = int64(14_000_000) - const eventCount = 200 - const wantOverflow = perEvent * eventCount - for i := 0; i < eventCount; i++ { - insertOfficeCostEvent(t, repo, fmt.Sprintf("cost-overflow-%d", i), "sess-overflow-pg", perEvent) - } - // A normal-sized session must still backfill correctly in the same pass - - // the whole point of F8 is that one overflowing row previously took the - // entire UPDATE down with it. - insertOfficeCostEvent(t, repo, "cost-normal", "sess-normal-pg", 1_000) - - if _, err := repo.BackfillSessionTokensCachedIn(context.Background()); err != nil { - t.Fatalf("backfill must not error on a ledger sum beyond int32 range: %v", err) - } - - var gotOverflow, gotNormal int64 - if err := repo.ro.QueryRowx(repo.ro.Rebind( - `SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-overflow-pg", - ).Scan(&gotOverflow); err != nil { - t.Fatalf("read overflow row: %v", err) - } - if gotOverflow != wantOverflow { - t.Errorf("tokens_cached_in = %d, want %d (sum beyond int32 range must survive the backfill)", - gotOverflow, wantOverflow) - } - if err := repo.ro.QueryRowx(repo.ro.Rebind( - `SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-normal-pg", - ).Scan(&gotNormal); err != nil { - t.Fatalf("read normal row: %v", err) - } - if gotNormal != 1_000 { - t.Errorf("tokens_cached_in for the non-overflowing session = %d, want 1000 "+ - "(a sibling session's overflow must not abort the whole backfill statement)", gotNormal) - } -} - // TestPostgresIncrementTaskSessionUsage_AccumulatesValuesBeyondInt32Range -// covers F8's other failure mode: IncrementTaskSessionUsage sets tokens_in, +// covers F8's other failure mode: IncrementTaskSessionUsageTx sets tokens_in, // tokens_cached_in, tokens_out and cost_subcents in one UPDATE. On an INTEGER // column that UPDATE fails once tokens_cached_in crosses the int32 ceiling, // silently stopping tokens_in/tokens_out/cost_subcents from updating too - @@ -145,8 +53,8 @@ func TestPostgresIncrementTaskSessionUsage_AccumulatesValuesBeyondInt32Range(t * seedPostgresTaskSession(t, repo, "task-inc-overflow-pg", "sess-inc-overflow-pg") const beyondInt32 = int64(2_800_000_000) - if err := repo.IncrementTaskSessionUsage(ctx, "sess-inc-overflow-pg", 100, beyondInt32, 200, 50); err != nil { - t.Fatalf("IncrementTaskSessionUsage must not error on a cached-token delta beyond int32 range: %v", err) + if err := repo.IncrementTaskSessionUsageTx(ctx, nil, "sess-inc-overflow-pg", 100, beyondInt32, 200, 50); err != nil { + t.Fatalf("IncrementTaskSessionUsageTx must not error on a cached-token delta beyond int32 range: %v", err) } var tokensIn, tokensCachedIn, tokensOut, costSubcents int64 diff --git a/apps/backend/internal/task/repository/sqlite/session_usage_test.go b/apps/backend/internal/task/repository/sqlite/session_usage_test.go index 67d5524b7e..f0cfdea311 100644 --- a/apps/backend/internal/task/repository/sqlite/session_usage_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_usage_test.go @@ -3,7 +3,6 @@ package sqlite import ( "context" "testing" - "time" ) // TestIncrementTaskSessionUsage_AccumulatesCachedInAcrossCalls confirms @@ -15,10 +14,10 @@ func TestIncrementTaskSessionUsage_AccumulatesCachedInAcrossCalls(t *testing.T) ctx := context.Background() seedForMsgTest(t, repo, "task-cached-usage", "sess-cached-usage", "turn-cached-usage") - if err := repo.IncrementTaskSessionUsage(ctx, "sess-cached-usage", 100, 50_000_000, 200, 50); err != nil { + if err := repo.IncrementTaskSessionUsageTx(ctx, nil, "sess-cached-usage", 100, 50_000_000, 200, 50); err != nil { t.Fatalf("first increment: %v", err) } - if err := repo.IncrementTaskSessionUsage(ctx, "sess-cached-usage", 10, 2_000, 20, 5); err != nil { + if err := repo.IncrementTaskSessionUsageTx(ctx, nil, "sess-cached-usage", 10, 2_000, 20, 5); err != nil { t.Fatalf("second increment: %v", err) } @@ -35,201 +34,36 @@ func TestIncrementTaskSessionUsage_AccumulatesCachedInAcrossCalls(t *testing.T) } } -// createOfficeCostEventsTable creates a minimal standalone copy of -// office_cost_events (owned by internal/office/repository/sqlite) so this -// package's migration tests can exercise the guarded backfill without -// importing the office package (which would create an import cycle back -// into internal/task). Column set mirrors office/repository/sqlite/base.go -// createCostTables. -func createOfficeCostEventsTable(t *testing.T, repo *Repository) { - t.Helper() - if _, err := repo.db.Exec(` - CREATE TABLE IF NOT EXISTS office_cost_events ( - id TEXT PRIMARY KEY, - session_id TEXT DEFAULT '', - task_id TEXT DEFAULT '', - agent_profile_id TEXT DEFAULT '', - project_id TEXT DEFAULT '', - model TEXT DEFAULT '', - provider TEXT DEFAULT '', - tokens_in INTEGER DEFAULT 0, - tokens_cached_in INTEGER DEFAULT 0, - tokens_out INTEGER DEFAULT 0, - cost_subcents INTEGER NOT NULL DEFAULT 0, - estimated INTEGER NOT NULL DEFAULT 0, - occurred_at TIMESTAMP NOT NULL, - created_at TIMESTAMP NOT NULL - )`); err != nil { - t.Fatalf("create office_cost_events: %v", err) - } -} +// TestGetTaskSessionAndListTaskSessions_SurfaceRollupColumns pins +// docs/specs/task-cost-ledger/spec.md AC-28/AC-29: both session read paths +// (GetTaskSession's single-row scan and ListTaskSessions' multi-row scan) +// must return the four usage/cost rollup columns IncrementTaskSessionUsageTx +// maintains, not just the underlying SQL row. +func TestGetTaskSessionAndListTaskSessions_SurfaceRollupColumns(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-rollup-surface", "sess-rollup-surface", "turn-rollup-surface") -func insertOfficeCostEvent(t *testing.T, repo *Repository, id, sessionID string, tokensCachedIn int64) { - t.Helper() - now := time.Now().UTC() - _, err := repo.db.Exec(repo.db.Rebind(` - INSERT INTO office_cost_events (id, session_id, tokens_cached_in, occurred_at, created_at) - VALUES (?, ?, ?, ?, ?) - `), id, sessionID, tokensCachedIn, now, now) + if err := repo.IncrementTaskSessionUsageTx(ctx, nil, "sess-rollup-surface", 80, 8_203_943, 44979, 79118); err != nil { + t.Fatalf("increment: %v", err) + } + + got, err := repo.GetTaskSession(ctx, "sess-rollup-surface") if err != nil { - t.Fatalf("insert office_cost_event %s: %v", id, err) + t.Fatalf("GetTaskSession: %v", err) } -} - -// TestBackfillSessionTokensCachedIn_SumsLedger proves the backfill invariant -// directly: task_sessions.tokens_cached_in must equal the sum of -// office_cost_events.tokens_cached_in for that session. This is the -// reconciliation the card asked for, exercised at the SQL layer. -func TestBackfillSessionTokensCachedIn_SumsLedger(t *testing.T) { - repo := newRepoForSessionTests(t) - ctx := context.Background() - seedForMsgTest(t, repo, "task-backfill", "sess-backfill", "turn-backfill") - createOfficeCostEventsTable(t, repo) - insertOfficeCostEvent(t, repo, "cost-1", "sess-backfill", 98_805_109) - insertOfficeCostEvent(t, repo, "cost-2", "sess-backfill", 1_194_891) - // A row for a different session must not leak into this session's total. - insertOfficeCostEvent(t, repo, "cost-other", "sess-other", 555) - - if _, err := repo.BackfillSessionTokensCachedIn(ctx); err != nil { - t.Fatalf("backfill: %v", err) + if got.TokensIn != 80 || got.TokensCachedIn != 8_203_943 || got.TokensOut != 44979 || got.CostSubcents != 79118 { + t.Errorf("GetTaskSession rollup = %+v, want (80, 8203943, 44979, 79118)", got) } - var got int64 - if err := repo.ro.QueryRowx(repo.ro.Rebind( - `SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-backfill", - ).Scan(&got); err != nil { - t.Fatalf("read row: %v", err) - } - if got != 100_000_000 { - t.Errorf("tokens_cached_in = %d, want 100000000 (sum of office_cost_events for this session)", got) - } - - // Idempotent: re-running against an unchanged ledger must not - // double-count the already-correct total. - if _, err := repo.BackfillSessionTokensCachedIn(ctx); err != nil { - t.Fatalf("second backfill: %v", err) - } - if err := repo.ro.QueryRowx(repo.ro.Rebind( - `SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-backfill", - ).Scan(&got); err != nil { - t.Fatalf("read row after second pass: %v", err) - } - if got != 100_000_000 { - t.Errorf("tokens_cached_in after second backfill pass = %d, want unchanged 100000000", got) - } -} - -// TestBackfillSessionTokensCachedIn_CorrectsDriftedRollup covers the case an -// unconditional recompute exists to repair: a session whose rollup went out -// of sync with the ledger without ever erroring. IncrementTaskSessionUsage -// is a best-effort `UPDATE ... WHERE id = ?` that silently matches zero rows -// if the session row doesn't exist yet (see -// TestIncrementTaskSessionUsage_UnknownSessionNoError), and the office -// subscriber only logs a Warn when that write fails while the ledger insert -// has already committed (event_subscribers.go handlePromptUsage). A backfill -// gated on "already nonzero" would treat this drifted-but-nonzero row as -// already correct and skip it forever; the unconditional recompute must -// instead overwrite it with the true ledger sum. -func TestBackfillSessionTokensCachedIn_CorrectsDriftedRollup(t *testing.T) { - repo := newRepoForSessionTests(t) - ctx := context.Background() - seedForMsgTest(t, repo, "task-drifted", "sess-drifted", "turn-drifted") - createOfficeCostEventsTable(t, repo) - insertOfficeCostEvent(t, repo, "cost-1", "sess-drifted", 98_805_109) - insertOfficeCostEvent(t, repo, "cost-2", "sess-drifted", 1_194_891) - - // Simulate the drift directly: the rollup already holds a nonzero but - // wrong value, as it would after a missed increment. - if err := repo.IncrementTaskSessionUsage(ctx, "sess-drifted", 0, 2_000, 0, 0); err != nil { - t.Fatalf("seed drifted rollup: %v", err) - } - - var before int64 - if err := repo.ro.QueryRowx(repo.ro.Rebind( - `SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-drifted", - ).Scan(&before); err != nil { - t.Fatalf("read row before backfill: %v", err) - } - if before != 2_000 { - t.Fatalf("test setup bug: rollup = %d, want the drifted seed value 2000", before) - } - - if _, err := repo.BackfillSessionTokensCachedIn(ctx); err != nil { - t.Fatalf("backfill: %v", err) - } - - var got int64 - if err := repo.ro.QueryRowx(repo.ro.Rebind( - `SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-drifted", - ).Scan(&got); err != nil { - t.Fatalf("read row after backfill: %v", err) - } - if got != 100_000_000 { - t.Errorf("tokens_cached_in after backfill = %d, want 100000000 (the drifted 2000 must be "+ - "overwritten with the full ledger sum, not skipped as already-nonzero)", got) - } -} - -// TestBackfillSessionTokensCachedIn_ZeroesRollupWithNoMatchingLedgerRows pins -// the behaviour when the ledger table exists but has no rows for a session -// whose rollup is already nonzero (e.g. the ledger rows for that session were -// deleted separately from the session itself - see workspace_deletion.go, -// which deletes office_cost_events before the task-side workspace in two -// separate transactions). The unconditional recompute is a COALESCE(...,0), -// so it deliberately zeroes tokens_cached_in in this case rather than leaving -// the stale value: the rollup is supposed to always equal the ledger sum for -// that session, and an empty ledger sums to zero. -func TestBackfillSessionTokensCachedIn_ZeroesRollupWithNoMatchingLedgerRows(t *testing.T) { - repo := newRepoForSessionTests(t) - ctx := context.Background() - seedForMsgTest(t, repo, "task-orphaned", "sess-orphaned", "turn-orphaned") - createOfficeCostEventsTable(t, repo) - - // Rollup already holds a nonzero value, but no office_cost_events rows - // exist for this session (simulates the ledger having been deleted). - if err := repo.IncrementTaskSessionUsage(ctx, "sess-orphaned", 0, 12_345, 0, 0); err != nil { - t.Fatalf("seed rollup: %v", err) - } - - if _, err := repo.BackfillSessionTokensCachedIn(ctx); err != nil { - t.Fatalf("backfill: %v", err) - } - - var got int64 - if err := repo.ro.QueryRowx(repo.ro.Rebind( - `SELECT tokens_cached_in FROM task_sessions WHERE id = ?`), "sess-orphaned", - ).Scan(&got); err != nil { - t.Fatalf("read row: %v", err) - } - if got != 0 { - t.Errorf("tokens_cached_in = %d, want 0 (no matching ledger rows sums to zero)", got) - } -} - -// TestBackfillSessionTokensCachedIn_SkipsAlreadyCorrectUntouchedSessions pins -// the boot-cost optimization: a session with no office_cost_events rows and a -// tokens_cached_in already at its default of 0 must be excluded by the -// UPDATE's WHERE clause, not just happen to compute the same value. Proven -// via the reported affected-row count (matches SQL UPDATE semantics on both -// SQLite and Postgres: rows matched by WHERE, not rows whose value changed), -// comparing a session the WHERE clause must match (has ledger rows) against -// one it must not (no ledger rows, untouched default). -func TestBackfillSessionTokensCachedIn_SkipsAlreadyCorrectUntouchedSessions(t *testing.T) { - repo := newRepoForSessionTests(t) - ctx := context.Background() - seedForMsgTest(t, repo, "task-untouched", "sess-untouched", "turn-untouched") - seedForMsgTest(t, repo, "task-with-ledger", "sess-with-ledger", "turn-with-ledger") - createOfficeCostEventsTable(t, repo) - insertOfficeCostEvent(t, repo, "cost-1", "sess-with-ledger", 500) - // sess-untouched: no office_cost_events rows, tokens_cached_in stays at - // its untouched default of 0. - - affected, err := repo.BackfillSessionTokensCachedIn(ctx) + list, err := repo.ListTaskSessions(ctx, "task-rollup-surface") if err != nil { - t.Fatalf("backfill: %v", err) + t.Fatalf("ListTaskSessions: %v", err) } - if affected != 1 { - t.Errorf("rows affected = %d, want 1 (only sess-with-ledger should match the WHERE clause; "+ - "sess-untouched has no ledger rows and was already 0)", affected) + if len(list) != 1 { + t.Fatalf("ListTaskSessions returned %d sessions, want 1", len(list)) + } + if list[0].TokensIn != 80 || list[0].TokensCachedIn != 8_203_943 || list[0].TokensOut != 44979 || list[0].CostSubcents != 79118 { + t.Errorf("ListTaskSessions rollup = %+v, want (80, 8203943, 44979, 79118)", list[0]) } } diff --git a/apps/backend/internal/task/repository/sqlite/usage_events.go b/apps/backend/internal/task/repository/sqlite/usage_events.go new file mode 100644 index 0000000000..8751b20ca6 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_events.go @@ -0,0 +1,305 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "go.uber.org/zap" + + internaldb "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/task/models" +) + +// usageEventTransientBackoffs is AC-32's fixed exponential backoff between +// transient-error retry attempts: 50 ms then 100 ms, base 50 ms, factor 2, no +// jitter. A single serial writer (AC-34) has no fleet of peers to +// desynchronize from, so jitter would only add variance. +var usageEventTransientBackoffs = [2]time.Duration{50 * time.Millisecond, 100 * time.Millisecond} + +// maxUsageEventTransientAttempts is AC-32's cap on transient-classified +// insert attempts (the whole AC-11 transaction, not the INSERT statement +// alone) before giving up. +const maxUsageEventTransientAttempts = 3 + +// CreateTaskUsageEvent inserts one task_usage_events row and atomically +// increments the owning session's task_sessions rollup columns in the same +// transaction (AC-11), classifying and reacting to insert failures per +// AC-32: +// +// - a unique violation on usage_event_id is reported as +// ErrDuplicateUsageEvent, with no retry and no rollup increment (AC-14, +// AC-32) - this is the one insert failure that is an expected outcome, +// not a fault; +// - a foreign-key violation is retried exactly once with session_id +// forced to "" (no rollup increment on that retry, since +// IncrementTaskSessionUsageTx no-ops on an empty session id); a second +// failure of any kind is returned as-is; +// - a transient failure (serialization failure, deadlock, SQLITE_BUSY / +// SQLITE_LOCKED) is retried in a fresh transaction, waiting per +// usageEventTransientBackoffs between attempts, up to +// maxUsageEventTransientAttempts total attempts; +// - anything else is returned as-is, uncounted against either retry +// budget. +// +// Before the first attempt, if event.SessionID is set, CreateTaskUsageEvent +// verifies the session actually belongs to event.TaskID (AC-33): a +// same-task session proceeds unchanged; a session belonging to a different +// task is recorded under event.TaskID with session_id cleared and no rollup +// increment - deliberately the same end state as the foreign-key retry +// above, so the two share one reaction even though only the FK case is a +// genuine driver-level retry. A session lookup that fails for any reason +// other than "no such session" is returned immediately, uncounted against +// either retry budget: it means the writer could not determine ownership at +// all, not that a mismatch was found. +func (r *Repository) CreateTaskUsageEvent(ctx context.Context, event *models.TaskUsageEvent) error { + working := *event + + if err := r.clearMismatchedUsageEventSession(ctx, &working); err != nil { + return err + } + + fkRetried := false + transientAttempts := 0 + + for { + err := r.insertUsageEventAndRollup(ctx, &working) + if err == nil { + return nil + } + + switch { + case isUsageEventUniqueViolation(err): + return ErrDuplicateUsageEvent + case internaldb.IsForeignKeyViolation(err) && !fkRetried: + fkRetried = true + working.SessionID = "" + continue + case internaldb.IsTransientError(err): + transientAttempts++ + if transientAttempts >= maxUsageEventTransientAttempts { + return err + } + if sleepErr := sleepOrDone(ctx, usageEventTransientBackoffs[transientAttempts-1]); sleepErr != nil { + return sleepErr + } + continue + default: + return err + } + } +} + +// clearMismatchedUsageEventSession implements AC-33's pre-insert ownership +// check: if event.SessionID is set, it must belong to event.TaskID. A +// same-task session (or a session that does not exist at all - the real +// insert's foreign-key violation handles that case) is left unchanged; a +// session belonging to a different task has its SessionID cleared, deliberately +// the same terminal handling as the foreign-key retry in CreateTaskUsageEvent's +// loop. A lookup error other than "no such session" is returned immediately, +// uncounted against any retry budget: it means ownership could not be +// determined at all, not that a mismatch was found. +func (r *Repository) clearMismatchedUsageEventSession(ctx context.Context, event *models.TaskUsageEvent) error { + if event.SessionID == "" { + return nil + } + ownerTaskID, err := r.taskIDForSession(ctx, event.SessionID) + switch { + case err != nil && !errors.Is(err, sql.ErrNoRows): + return fmt.Errorf("verify usage event session ownership: %w", err) + case err == nil && ownerTaskID != event.TaskID: + if r.log != nil { + r.log.Warn("usage event session does not belong to its task; recording without session attribution", + zap.String("usage_event_id", event.UsageEventID), + zap.String("task_id", event.TaskID), + zap.String("session_id", event.SessionID), + zap.String("session_owner_task_id", ownerTaskID)) + } + event.SessionID = "" + } + return nil +} + +// sleepOrDone waits d, returning ctx.Err() early if ctx is cancelled first, +// so a shutdown drain deadline (AC-34) can interrupt a queued backoff +// instead of blocking the writer's worker goroutine past it. +func sleepOrDone(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// taskIDForSession returns the task_id owning sessionID, or sql.ErrNoRows if +// no such session exists. +func (r *Repository) taskIDForSession(ctx context.Context, sessionID string) (string, error) { + var taskID string + err := r.db.QueryRowxContext(ctx, r.db.Rebind( + `SELECT task_id FROM task_sessions WHERE id = ?`), sessionID).Scan(&taskID) + return taskID, err +} + +// insertUsageEventAndRollup is AC-11's atomic unit: the ledger insert and +// the task_sessions rollup increment, applied in a single transaction so no +// committed state exists in which one landed and the other did not. Returns +// the raw, unclassified driver error on failure - classification and +// reaction belong to CreateTaskUsageEvent. +func (r *Repository) insertUsageEventAndRollup(ctx context.Context, event *models.TaskUsageEvent) error { + if r.failUsageEventAttempts > 0 { + r.failUsageEventAttempts-- + return r.failUsageEventErr + } + + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if err := r.insertUsageEventRowTx(ctx, tx, event); err != nil { + return err + } + + if r.failUsageEventRollupAttempts > 0 { + r.failUsageEventRollupAttempts-- + return r.failUsageEventRollupErr + } + + if r.usageEventPreRollupHook != nil { + r.usageEventPreRollupHook() + } + + if event.SessionID != "" { + cachedIn := coalesceInt64(event.TokensCachedRead) + coalesceInt64(event.TokensCachedWrite) + if err := r.IncrementTaskSessionUsageTx(ctx, tx, event.SessionID, + event.TokensIn, cachedIn, coalesceInt64(event.TokensOut), event.CostSubcents); err != nil { + return err + } + } + + return tx.Commit() +} + +// coalesceInt64 treats a nil *int64 as zero (AC-12): a not-recorded token +// count contributes zero to the rollup rather than nulling out the whole +// sum. +func coalesceInt64(v *int64) int64 { + if v == nil { + return 0 + } + return *v +} + +func (r *Repository) insertUsageEventRowTx(ctx context.Context, tx *sqlx.Tx, event *models.TaskUsageEvent) error { + _, err := tx.ExecContext(ctx, r.db.Rebind(` + INSERT INTO task_usage_events ( + usage_event_id, task_id, session_id, turn_id, + agent_profile_id, agent_type, model, provider, + tokens_in, tokens_cached_read, tokens_cached_write, tokens_out, tokens_thought, + tokens_total, cost_subcents, cost_source, estimated, + rate_input_per_million, rate_cached_read_per_million, + rate_cached_write_per_million, rate_output_per_million, + pricing_catalog_version, contract_version, occurred_at, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), event.UsageEventID, event.TaskID, nullableString(event.SessionID), nullableString(event.TurnID), + event.AgentProfileID, event.AgentType, event.Model, event.Provider, + event.TokensIn, event.TokensCachedRead, event.TokensCachedWrite, event.TokensOut, event.TokensThought, + event.TokensTotal, event.CostSubcents, event.CostSource, dialect.BoolToInt(event.Estimated), + event.RateInputPerMillion, event.RateCachedReadPerMillion, + event.RateCachedWritePerMillion, event.RateOutputPerMillion, + nullableString(event.PricingCatalogVersion), event.ContractVersion, event.OccurredAt, event.CreatedAt) + return err +} + +// ListTaskUsageEvents returns taskID's ledger rows ordered by +// (occurred_at, id) ascending (AC-16), which is a total order over the +// table: no two rows compare equal, so repeated reads return an identical +// sequence. limit <= 0 means no limit. An unknown task and a task with no +// rows both return an empty, non-nil slice and a nil error. +func (r *Repository) ListTaskUsageEvents(ctx context.Context, taskID string, limit int) ([]*models.TaskUsageEvent, error) { + query := ` + SELECT id, usage_event_id, task_id, session_id, turn_id, + agent_profile_id, agent_type, model, provider, + tokens_in, tokens_cached_read, tokens_cached_write, tokens_out, tokens_thought, + tokens_total, cost_subcents, cost_source, estimated, + rate_input_per_million, rate_cached_read_per_million, + rate_cached_write_per_million, rate_output_per_million, + pricing_catalog_version, contract_version, occurred_at, created_at + FROM task_usage_events + WHERE task_id = ? + ORDER BY occurred_at ASC, id ASC + ` + args := []interface{}{taskID} + if limit > 0 { + query += ` LIMIT ?` + args = append(args, limit) + } + + rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(query), args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + events := make([]*models.TaskUsageEvent, 0) + for rows.Next() { + event, err := scanTaskUsageEventRow(rows) + if err != nil { + return nil, err + } + events = append(events, event) + } + if err := rows.Err(); err != nil { + return nil, err + } + return events, nil +} + +func scanTaskUsageEventRow(row *sqlx.Rows) (*models.TaskUsageEvent, error) { + var ( + event models.TaskUsageEvent + sessionID, turnID, pricingCatalogVersion sql.NullString + tokensCachedRead, tokensCachedWrite, tokensOut, tokensThought sql.NullInt64 + rateInput, rateCachedRead, rateCachedWrite, rateOutput sql.NullInt64 + ) + if err := row.Scan( + &event.ID, &event.UsageEventID, &event.TaskID, &sessionID, &turnID, + &event.AgentProfileID, &event.AgentType, &event.Model, &event.Provider, + &event.TokensIn, &tokensCachedRead, &tokensCachedWrite, &tokensOut, &tokensThought, + &event.TokensTotal, &event.CostSubcents, &event.CostSource, &event.Estimated, + &rateInput, &rateCachedRead, &rateCachedWrite, &rateOutput, + &pricingCatalogVersion, &event.ContractVersion, &event.OccurredAt, &event.CreatedAt, + ); err != nil { + return nil, err + } + + event.SessionID = sessionID.String + event.TurnID = turnID.String + event.PricingCatalogVersion = pricingCatalogVersion.String + event.TokensCachedRead = nullInt64ToPtr(tokensCachedRead) + event.TokensCachedWrite = nullInt64ToPtr(tokensCachedWrite) + event.TokensOut = nullInt64ToPtr(tokensOut) + event.TokensThought = nullInt64ToPtr(tokensThought) + event.RateInputPerMillion = nullInt64ToPtr(rateInput) + event.RateCachedReadPerMillion = nullInt64ToPtr(rateCachedRead) + event.RateCachedWritePerMillion = nullInt64ToPtr(rateCachedWrite) + event.RateOutputPerMillion = nullInt64ToPtr(rateOutput) + + return &event, nil +} + +func nullInt64ToPtr(v sql.NullInt64) *int64 { + if !v.Valid { + return nil + } + value := v.Int64 + return &value +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_events_atomicity_test.go b/apps/backend/internal/task/repository/sqlite/usage_events_atomicity_test.go new file mode 100644 index 0000000000..7caac0bfd0 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_events_atomicity_test.go @@ -0,0 +1,71 @@ +package sqlite + +// Non-transient rollup-failure atomicity for CreateTaskUsageEvent +// (docs/specs/task-cost-ledger/spec.md AC-11, AC-32(d), AC-36): the ledger +// insert and the task_sessions rollup increment are applied in one +// transaction, so a failure on either side rolls both back rather than +// leaving a partial write. This is the ledger-writer replacement for the +// deleted office/service test +// TestPromptUsage_RollupFailureRollsBackLedgerInsertThenRetrySucceeds (AC-21, +// AC-36): the recovery path PR #2606 review asked to see covered - force a +// rollup failure, then redeliver the same usage_event_id - now proven at the +// writer that actually owns the transaction, since internal/task/usage's +// Writer no longer shares one with an Office caller. + +import ( + "context" + "errors" + "testing" +) + +func TestCreateTaskUsageEvent_NonTransientFailureRollsBackThenRedeliveryOfSameIDSucceeds(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-nontransient") + createUsageEventsTestSession(t, repo, "session-nontransient", "task-nontransient") + + repo.failUsageEventRollupAttempts = 1 + repo.failUsageEventRollupErr = errors.New("injected rollup failure") + + event := newTestUsageEvent("evt-nontransient", "task-nontransient", "session-nontransient") + + // First delivery: the failpoint fires after the ledger row insert has + // already been attempted inside the transaction, but before the rollup + // increment - an error that is neither a duplicate, a foreign-key + // violation, nor transient, so CreateTaskUsageEvent returns it + // immediately with no internal retry. Because the insert and the rollup + // share one transaction, the deferred rollback unwinds the already + // -attempted insert along with everything else; a non-atomic + // implementation that committed the insert in its own transaction before + // attempting the rollup would leave the row behind here. + err := repo.CreateTaskUsageEvent(context.Background(), event) + if err == nil { + t.Fatal("expected the injected rollup failure to surface, got nil") + } + if errors.Is(err, ErrDuplicateUsageEvent) { + t.Fatalf("error = ErrDuplicateUsageEvent, want the raw injected failure") + } + if got := countTaskUsageEventRows(t, repo); got != 0 { + t.Fatalf("row count after failed attempt = %d, want 0 (ledger insert must roll back with the failed rollup)", got) + } + tokensIn, tokensCachedIn, tokensOut, costSubcents := readTaskSessionRollup(t, repo, "session-nontransient") + if tokensIn != 0 || tokensCachedIn != 0 || tokensOut != 0 || costSubcents != 0 { + t.Fatalf("rollup after failed attempt = (%d,%d,%d,%d), want all zero: a rolled-back attempt must not partially apply", + tokensIn, tokensCachedIn, tokensOut, costSubcents) + } + + // Redelivery of the same usage_event_id, this time with nothing armed to + // fail. Because nothing committed on the first attempt, the unique index + // must not treat this as a duplicate. + redelivered := newTestUsageEvent("evt-nontransient", "task-nontransient", "session-nontransient") + if err := repo.CreateTaskUsageEvent(context.Background(), redelivered); err != nil { + t.Fatalf("redelivered CreateTaskUsageEvent: %v", err) + } + + if got := countTaskUsageEventRows(t, repo); got != 1 { + t.Fatalf("row count after retry = %d, want 1", got) + } + tokensIn, tokensCachedIn, tokensOut, costSubcents = readTaskSessionRollup(t, repo, "session-nontransient") + if tokensIn != 100 || tokensCachedIn != 25 || tokensOut != 30 || costSubcents != 42 { + t.Errorf("rollup after retry = (%d,%d,%d,%d), want (100,25,30,42)", tokensIn, tokensCachedIn, tokensOut, costSubcents) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_events_deadlock_postgres_test.go b/apps/backend/internal/task/repository/sqlite/usage_events_deadlock_postgres_test.go new file mode 100644 index 0000000000..b05b9356bd --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_events_deadlock_postgres_test.go @@ -0,0 +1,174 @@ +package sqlite + +// TestPostgresCreateTaskUsageEvent_RealDeadlockIsClassifiedTransientAndRetried +// closes Review Round 2's M2 finding: the other AC-32 transient-retry tests +// (usage_events_transient_test.go) only prove the retry loop's control flow +// against an injected failpoint error, never against a genuine +// driver-reported 40001/40P01. internal/db's classifier already has focused +// unit coverage for those codes via synthetic *pgconn.PgError values +// (migration_errors_test.go); what was missing was proof that a real +// Postgres deadlock, raised against the exact unmodified production write +// path, actually reaches that classifier and gets retried rather than +// surfaced as a fatal error. +// +// Constructing a genuine deadlock here needs two real, separate connections +// (SQLite's single test connection can't produce true concurrent contention) +// and a lock-escalation pattern that survives Postgres's row-lock +// compatibility rules: +// +// - CreateTaskUsageEvent's ledger INSERT references task_sessions via a +// foreign key, which takes FOR KEY SHARE on the referenced session row. +// - Its rollup UPDATE (IncrementTaskSessionUsageTx) never touches the +// session's primary key, so it only ever requests FOR NO KEY UPDATE - +// the weakest lock that still conflicts with a concurrent FOR NO KEY +// UPDATE (row-lock modes, strongest to weakest: FOR UPDATE > FOR NO KEY +// UPDATE > FOR SHARE > FOR KEY SHARE; FOR NO KEY UPDATE conflicts with +// itself but not with FOR KEY SHARE). +// - A plain antagonist UPDATE that also avoids the primary key only ever +// takes FOR NO KEY UPDATE too, which is compatible with the production +// transaction's held FOR KEY SHARE - it will not deadlock with N +// concurrent real writers alone. The antagonist here deliberately +// escalates to FOR UPDATE via a second, explicit `SELECT ... FOR +// UPDATE`, which does conflict with production's held FOR KEY SHARE, +// closing the cycle. +// +// The hook is paused only on the antagonist's side of the transaction +// timeline: production's ledger insert runs first and pauses inside +// insertUsageEventAndRollup (before the rollup UPDATE) so the antagonist's +// first statement can be issued and its lock granted; production is then +// released into its rollup UPDATE (which blocks on the antagonist's FOR NO +// KEY UPDATE) before the antagonist's FOR UPDATE is issued (which then +// blocks on production's held FOR KEY SHARE) - so production's wait +// registers first and Postgres's deadlock detector reliably picks it as the +// victim, exercising exactly the retry path AC-32(c) describes. Verified via +// the real Postgres server log during development: a genuine `deadlock +// detected` (40P01) naming production's own rollup UPDATE statement as one +// of the two waiters. +// +// Skips unless KANDEV_TEST_POSTGRES_DSN is set. + +import ( + "context" + "database/sql" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/kandev/kandev/internal/testutil" +) + +func TestPostgresCreateTaskUsageEvent_RealDeadlockIsClassifiedTransientAndRetried(t *testing.T) { + dsn := testutil.PostgresDSNFromEnv(t) + db := testutil.OpenIsolatedPostgres(t, dsn) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + seedPostgresTaskSession(t, repo, "task-deadlock-pg", "session-deadlock-pg") + + // The antagonist needs its own physical connection: repo.db (used by + // testutil.OpenIsolatedPostgres) caps MaxOpenConns at 1, so sharing it + // would starve production of a connection entirely instead of + // constructing genuine cross-connection Postgres lock contention. + var searchPath string + if err := repo.db.Get(&searchPath, `SHOW search_path`); err != nil { + t.Fatalf("show search_path: %v", err) + } + antagonistDB, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatalf("open antagonist connection: %v", err) + } + defer func() { _ = antagonistDB.Close() }() + if _, err := antagonistDB.Exec("SET search_path TO " + searchPath); err != nil { + t.Fatalf("set antagonist search_path: %v", err) + } + + antagonistTx, err := antagonistDB.BeginTx(context.Background(), nil) + if err != nil { + t.Fatalf("begin antagonist transaction: %v", err) + } + t.Cleanup(func() { _ = antagonistTx.Rollback() }) + if _, err := antagonistTx.Exec( + `UPDATE task_sessions SET tokens_in = tokens_in WHERE id = $1`, "session-deadlock-pg", + ); err != nil { + t.Fatalf("antagonist FOR NO KEY UPDATE statement: %v", err) + } + + insertedCh := make(chan struct{}) + releaseCh := make(chan struct{}) + attempts := 0 + var hookPaused bool + repo.usageEventPreRollupHook = func() { + attempts++ + // Only pause on the first attempt: if production loses the + // deadlock and retries, insertUsageEventAndRollup calls this hook + // again on every subsequent attempt, and closing insertedCh a + // second time would panic. + if hookPaused { + return + } + hookPaused = true + close(insertedCh) + <-releaseCh + } + + mainErrCh := make(chan error, 1) + go func() { + event := newTestUsageEvent("evt-deadlock-pg", "task-deadlock-pg", "session-deadlock-pg") + mainErrCh <- repo.CreateTaskUsageEvent(context.Background(), event) + }() + + <-insertedCh + close(releaseCh) // production proceeds into its rollup UPDATE, blocking on the antagonist's held FOR NO KEY UPDATE + + time.Sleep(200 * time.Millisecond) // ensure production's wait registers before the antagonist's escalation + + antagonistErrCh := make(chan error, 1) + go func() { + _, escalateErr := antagonistTx.Exec(`SELECT id FROM task_sessions WHERE id = $1 FOR UPDATE`, "session-deadlock-pg") + antagonistErrCh <- escalateErr + }() + + var mainErr, antagonistErr error + var mainDone, antagonistDone bool + for !mainDone || !antagonistDone { + select { + case mainErr = <-mainErrCh: + mainDone = true + case antagonistErr = <-antagonistErrCh: + antagonistDone = true + // Release the antagonist's transaction the instant its own call + // returns rather than after production's: if the deadlock + // detector picks production as the victim, production's retry + // needs this FOR UPDATE lock released to proceed, so waiting on + // mainErrCh first would self-deadlock the test harness (not + // Postgres). + if antagonistErr != nil { + _ = antagonistTx.Rollback() + } else { + _ = antagonistTx.Commit() + } + } + } + + if mainErr != nil { + t.Fatalf("CreateTaskUsageEvent must transparently retry past a real deadlock, got: %v", mainErr) + } + if attempts < 2 { + t.Fatalf("insertUsageEventAndRollup ran %d attempt(s), want at least 2 - "+ + "either no real deadlock occurred or the transient retry did not fire", attempts) + } + + var count int + if err := repo.db.Get(&count, `SELECT COUNT(*) FROM task_usage_events`); err != nil { + t.Fatalf("count rows: %v", err) + } + if count != 1 { + t.Fatalf("row count = %d, want 1 (the aborted first attempt must not leave a row)", count) + } + tokensIn, tokensCachedIn, tokensOut, costSubcents := readTaskSessionRollup(t, repo, "session-deadlock-pg") + if tokensIn != 100 || tokensCachedIn != 25 || tokensOut != 30 || costSubcents != 42 { + t.Errorf("rollup = (%d,%d,%d,%d), want (100,25,30,42) - "+ + "the aborted first attempt must not have partially or doubly applied", tokensIn, tokensCachedIn, tokensOut, costSubcents) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_events_list_test.go b/apps/backend/internal/task/repository/sqlite/usage_events_list_test.go new file mode 100644 index 0000000000..a5781457cc --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_events_list_test.go @@ -0,0 +1,126 @@ +package sqlite + +// Behavioral coverage for ListTaskUsageEvents (docs/specs/task-cost-ledger/spec.md +// AC-16): (occurred_at, id) ascending total order, limit semantics, and the +// empty-non-nil-slice contract for an unknown task or a task with no rows. + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestListTaskUsageEvents_OrdersByOccurredAtThenID(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-order") + + base := time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC) + // Two rows share the same occurred_at, so only insertion-order id can + // break the tie; the third row is deliberately inserted with an earlier + // occurred_at than the first two, to prove ordering is by occurred_at + // first, not by insertion/id order. + first := newTestUsageEvent("evt-order-a", "task-order", "") + first.OccurredAt = base + second := newTestUsageEvent("evt-order-b", "task-order", "") + second.OccurredAt = base + third := newTestUsageEvent("evt-order-c", "task-order", "") + third.OccurredAt = base.Add(-time.Hour) + + for _, event := range []*models.TaskUsageEvent{first, second, third} { + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("CreateTaskUsageEvent(%s): %v", event.UsageEventID, err) + } + } + + events, err := repo.ListTaskUsageEvents(context.Background(), "task-order", 0) + if err != nil { + t.Fatalf("ListTaskUsageEvents: %v", err) + } + if len(events) != 3 { + t.Fatalf("len(events) = %d, want 3", len(events)) + } + wantOrder := []string{"evt-order-c", "evt-order-a", "evt-order-b"} + for i, want := range wantOrder { + if events[i].UsageEventID != want { + t.Errorf("events[%d].UsageEventID = %q, want %q", i, events[i].UsageEventID, want) + } + } +} + +func TestListTaskUsageEvents_LimitNonPositiveMeansNoLimit(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-nolimit") + for _, id := range []string{"evt-nl-1", "evt-nl-2", "evt-nl-3"} { + if err := repo.CreateTaskUsageEvent(context.Background(), newTestUsageEvent(id, "task-nolimit", "")); err != nil { + t.Fatalf("CreateTaskUsageEvent(%s): %v", id, err) + } + } + + for _, limit := range []int{0, -1} { + events, err := repo.ListTaskUsageEvents(context.Background(), "task-nolimit", limit) + if err != nil { + t.Fatalf("ListTaskUsageEvents(limit=%d): %v", limit, err) + } + if len(events) != 3 { + t.Errorf("ListTaskUsageEvents(limit=%d) len = %d, want 3", limit, len(events)) + } + } +} + +func TestListTaskUsageEvents_LimitAppliedInAscendingOrder(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-limit") + + base := time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC) + for i, id := range []string{"evt-lim-1", "evt-lim-2", "evt-lim-3"} { + event := newTestUsageEvent(id, "task-limit", "") + event.OccurredAt = base.Add(time.Duration(i) * time.Minute) + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("CreateTaskUsageEvent(%s): %v", id, err) + } + } + + events, err := repo.ListTaskUsageEvents(context.Background(), "task-limit", 2) + if err != nil { + t.Fatalf("ListTaskUsageEvents: %v", err) + } + if len(events) != 2 { + t.Fatalf("len(events) = %d, want 2", len(events)) + } + if events[0].UsageEventID != "evt-lim-1" || events[1].UsageEventID != "evt-lim-2" { + t.Errorf("events = [%s, %s], want [evt-lim-1, evt-lim-2]", events[0].UsageEventID, events[1].UsageEventID) + } +} + +func TestListTaskUsageEvents_UnknownTask_ReturnsEmptyNonNilSlice(t *testing.T) { + repo := newUsageEventsTestRepo(t) + + events, err := repo.ListTaskUsageEvents(context.Background(), "task-does-not-exist", 0) + if err != nil { + t.Fatalf("ListTaskUsageEvents: %v", err) + } + if events == nil { + t.Fatal("events = nil, want a non-nil empty slice") + } + if len(events) != 0 { + t.Errorf("len(events) = %d, want 0", len(events)) + } +} + +func TestListTaskUsageEvents_TaskWithNoRows_ReturnsEmptyNonNilSlice(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-empty") + + events, err := repo.ListTaskUsageEvents(context.Background(), "task-empty", 0) + if err != nil { + t.Fatalf("ListTaskUsageEvents: %v", err) + } + if events == nil { + t.Fatal("events = nil, want a non-nil empty slice") + } + if len(events) != 0 { + t.Errorf("len(events) = %d, want 0", len(events)) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_events_migration_postgres_test.go b/apps/backend/internal/task/repository/sqlite/usage_events_migration_postgres_test.go new file mode 100644 index 0000000000..9b880ea394 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_events_migration_postgres_test.go @@ -0,0 +1,107 @@ +package sqlite + +// AC-28 coverage: task_sessions.cost_subcents, .tokens_in and .tokens_out +// must be BIGINT on Postgres, mirroring the same 64-bit rule as +// task_usage_events and the already-BIGINT tokens_cached_in. SQLite's +// INTEGER is already 64-bit, so this dialect-sensitive widening is only +// observable against a real Postgres instance. +// +// Skips unless KANDEV_TEST_POSTGRES_DSN is set. + +import ( + "testing" + + "github.com/kandev/kandev/internal/testutil" +) + +func postgresColumnDataType(t *testing.T, repo *Repository, table, column string) string { + t.Helper() + var dataType string + err := repo.db.Get(&dataType, ` + SELECT data_type + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = $1 + AND column_name = $2 + `, table, column) + if err != nil { + t.Fatalf("inspect %s.%s type: %v", table, column, err) + } + return dataType +} + +// TestPostgresTaskSessionsRollupColumns_WidenedToBigint proves a fresh +// Postgres database ends up with all three columns as BIGINT after schema +// init runs migrateSessionsAddCostColumns (creates them as INTEGER) followed +// by migrateTaskSessionsRollupColumnsToBigint (widens them). +func TestPostgresTaskSessionsRollupColumns_WidenedToBigint(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + + for _, column := range []string{"cost_subcents", "tokens_in", "tokens_out"} { + if got := postgresColumnDataType(t, repo, "task_sessions", column); got != "bigint" { + t.Errorf("task_sessions.%s data type = %q, want bigint", column, got) + } + } +} + +// TestPostgresTaskSessionsRollupColumns_LegacyIntegerSurvivesWidening +// simulates a pre-AC-28 database where these columns are still INTEGER +// (int4), and proves the migration widens them in place without erroring and +// without losing existing data - the same table-rebuild-avoidance shape as +// the priority-to-text migration. +func TestPostgresTaskSessionsRollupColumns_LegacyIntegerSurvivesWidening(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + + // Rewind to a pre-AC-28 schema: narrow the columns back to INTEGER, as a + // legacy database would have them before this migration ever ran. + for _, column := range []string{"cost_subcents", "tokens_in", "tokens_out"} { + if _, err := repo.db.Exec(`ALTER TABLE task_sessions ALTER COLUMN ` + column + ` TYPE INTEGER`); err != nil { + t.Fatalf("rewind %s to integer: %v", column, err) + } + } + + seedPostgresTaskSession(t, repo, "task-legacy-widen-pg", "session-legacy-widen-pg") + if _, err := repo.db.Exec(repo.db.Rebind(` + UPDATE task_sessions SET cost_subcents = ?, tokens_in = ?, tokens_out = ? + WHERE id = ? + `), int64(123_456), int64(789), int64(321), "session-legacy-widen-pg"); err != nil { + t.Fatalf("seed legacy values: %v", err) + } + + // Re-run the migration as a new-binary boot would. + repo.migrateTaskSessionsRollupColumnsToBigint() + + for _, column := range []string{"cost_subcents", "tokens_in", "tokens_out"} { + if got := postgresColumnDataType(t, repo, "task_sessions", column); got != "bigint" { + t.Errorf("task_sessions.%s data type after re-migration = %q, want bigint", column, got) + } + } + + var costSubcents, tokensIn, tokensOut int64 + if err := repo.db.QueryRowx(repo.db.Rebind( + `SELECT cost_subcents, tokens_in, tokens_out FROM task_sessions WHERE id = ?`), "session-legacy-widen-pg", + ).Scan(&costSubcents, &tokensIn, &tokensOut); err != nil { + t.Fatalf("read row: %v", err) + } + if costSubcents != 123_456 || tokensIn != 789 || tokensOut != 321 { + t.Errorf("values after widening = (%d,%d,%d), want (123456,789,321) - existing data must survive", + costSubcents, tokensIn, tokensOut) + } + + // The scenario this covers: a value beyond int4's ceiling must now commit + // where a pre-widening column would reject it with "integer out of range". + const beyondInt32 = int64(9_000_000_000) + if _, err := repo.db.Exec(repo.db.Rebind( + `UPDATE task_sessions SET cost_subcents = ? WHERE id = ?`), beyondInt32, "session-legacy-widen-pg", + ); err != nil { + t.Fatalf("update with a value beyond int32 range must commit on a BIGINT column: %v", err) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_events_migrations.go b/apps/backend/internal/task/repository/sqlite/usage_events_migrations.go new file mode 100644 index 0000000000..5bc9a32c8a --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_events_migrations.go @@ -0,0 +1,28 @@ +package sqlite + +import ( + "github.com/kandev/kandev/internal/db/dialect" +) + +// migrateTaskSessionsRollupColumnsToBigint widens +// task_sessions.cost_subcents, .tokens_in and .tokens_out from INTEGER to +// BIGINT (AC-28, docs/specs/task-cost-ledger/spec.md), so all four rollup +// columns - these three plus the already-BIGINT tokens_cached_in - satisfy +// the same 64-bit rule as task_usage_events. Idempotent and dialect-aware in +// the established runMigrations() shape: on SQLite this is a no-op, because +// SQLite's INTEGER is already 64-bit; on Postgres, widening INTEGER (or +// SMALLINT) to BIGINT is a lossless type change that Postgres accepts with no +// USING clause, and re-running ALTER COLUMN ... TYPE BIGINT against a column +// that is already BIGINT is itself a no-op, so no pre-check is needed to make +// replay safe. +func (r *Repository) migrateTaskSessionsRollupColumnsToBigint() { + if !dialect.IsPostgres(r.db.DriverName()) { + return + } + r.migrate.Apply("task_sessions.cost_subcents.bigint", + `ALTER TABLE task_sessions ALTER COLUMN cost_subcents TYPE BIGINT`) + r.migrate.Apply("task_sessions.tokens_in.bigint", + `ALTER TABLE task_sessions ALTER COLUMN tokens_in TYPE BIGINT`) + r.migrate.Apply("task_sessions.tokens_out.bigint", + `ALTER TABLE task_sessions ALTER COLUMN tokens_out TYPE BIGINT`) +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_events_postgres_test.go b/apps/backend/internal/task/repository/sqlite/usage_events_postgres_test.go new file mode 100644 index 0000000000..3bd3144f44 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_events_postgres_test.go @@ -0,0 +1,222 @@ +package sqlite + +// Postgres parity coverage for CreateTaskUsageEvent (docs/specs/task-cost-ledger/spec.md +// AC-11, AC-32): the insert-failure classification calls internaldb.IsForeignKeyViolation +// and internaldb.IsTransientError, which branch on a typed pgconn.PgError on +// Postgres versus a string-matched go-sqlite3 error on SQLite - this file +// proves the classification (and the resulting retry behavior) also holds +// against a real Postgres driver error, not just SQLite's. +// +// Skips unless KANDEV_TEST_POSTGRES_DSN is set. + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/testutil" +) + +func TestPostgresCreateTaskUsageEvent_HappyPath_InsertsRowAndIncrementsRollup(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + seedPostgresTaskSession(t, repo, "task-happy-pg", "session-happy-pg") + + event := newTestUsageEvent("evt-happy-pg", "task-happy-pg", "session-happy-pg") + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("CreateTaskUsageEvent: %v", err) + } + + var count int + if err := repo.db.Get(&count, `SELECT COUNT(*) FROM task_usage_events`); err != nil { + t.Fatalf("count rows: %v", err) + } + if count != 1 { + t.Fatalf("row count = %d, want 1", count) + } + + tokensIn, tokensCachedIn, tokensOut, costSubcents := readTaskSessionRollup(t, repo, "session-happy-pg") + if tokensIn != 100 || tokensCachedIn != 25 || tokensOut != 30 || costSubcents != 42 { + t.Errorf("rollup = (%d,%d,%d,%d), want (100,25,30,42)", tokensIn, tokensCachedIn, tokensOut, costSubcents) + } +} + +// TestPostgresListTaskUsageEvents_EstimatedRoundTrips pins the INTEGER-to-bool +// scan used by the read path. The insert stores the flag as 0/1 for both +// dialects, so this keeps the Postgres driver conversion explicit. +func TestPostgresListTaskUsageEvents_EstimatedRoundTrips(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + seedPostgresTask(t, repo, "task-estimated-pg") + + event := newTestUsageEvent("evt-estimated-pg", "task-estimated-pg", "") + event.Estimated = true + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("CreateTaskUsageEvent: %v", err) + } + + events, err := repo.ListTaskUsageEvents(context.Background(), "task-estimated-pg", 0) + if err != nil { + t.Fatalf("ListTaskUsageEvents: %v", err) + } + if len(events) != 1 || !events[0].Estimated { + t.Fatalf("events = %+v, want one estimated event", events) + } +} + +func TestPostgresCreateTaskUsageEvent_DuplicateUsageEventID_ReturnsErrDuplicateNoRollup(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + seedPostgresTaskSession(t, repo, "task-dup-pg", "session-dup-create-pg") + + first := newTestUsageEvent("evt-dup-create-pg", "task-dup-pg", "session-dup-create-pg") + if err := repo.CreateTaskUsageEvent(context.Background(), first); err != nil { + t.Fatalf("first CreateTaskUsageEvent: %v", err) + } + + redelivered := newTestUsageEvent("evt-dup-create-pg", "task-dup-pg", "session-dup-create-pg") + err = repo.CreateTaskUsageEvent(context.Background(), redelivered) + if err != ErrDuplicateUsageEvent { + t.Fatalf("redelivered CreateTaskUsageEvent error = %v, want ErrDuplicateUsageEvent", err) + } + + tokensIn, _, _, _ := readTaskSessionRollup(t, repo, "session-dup-create-pg") + if tokensIn != 100 { + t.Errorf("tokens_in = %d, want 100 (redelivery must not double-increment the rollup)", tokensIn) + } +} + +func TestPostgresCreateTaskUsageEvent_SessionForeignKeyViolation_RetriesOnceWithSessionCleared(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + seedPostgresTask(t, repo, "task-fk-retry-pg") + + event := newTestUsageEvent("evt-fk-retry-pg", "task-fk-retry-pg", "session-does-not-exist-pg") + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("CreateTaskUsageEvent: %v", err) + } + + var sessionID *string + if err := repo.db.QueryRowx(repo.db.Rebind( + `SELECT session_id FROM task_usage_events WHERE usage_event_id = ?`), "evt-fk-retry-pg", + ).Scan(&sessionID); err != nil { + t.Fatalf("read row: %v", err) + } + if sessionID != nil { + t.Errorf("session_id = %v, want nil (FK retry must clear it)", *sessionID) + } +} + +func TestPostgresCreateTaskUsageEvent_SecondForeignKeyFailure_ReturnsError(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + + event := newTestUsageEvent("evt-fk-double-pg", "task-does-not-exist-pg", "session-does-not-exist-pg") + createErr := repo.CreateTaskUsageEvent(context.Background(), event) + if createErr == nil { + t.Fatal("expected an error when both task_id and session_id are invalid, got nil") + } + if createErr == ErrDuplicateUsageEvent { + t.Fatalf("error = ErrDuplicateUsageEvent, want a foreign-key failure") + } + + var count int + if err := repo.db.Get(&count, `SELECT COUNT(*) FROM task_usage_events`); err != nil { + t.Fatalf("count rows: %v", err) + } + if count != 0 { + t.Errorf("row count = %d, want 0 (both attempts must roll back)", count) + } +} + +// TestPostgresCreateTaskUsageEvent_TokensOutCrossesInt32ThroughRealWriter +// covers AC-28 for tokens_out specifically: session_usage_postgres_test.go +// only pushes tokens_cached_in (already BIGINT before this feature) past +// int32, and TestPostgresTaskSessionsRollupColumns_LegacyIntegerSurvivesWidening +// pushes cost_subcents past int32 via a raw UPDATE that bypasses the ledger +// writer entirely - a version of either test passing before the migration +// widened tokens_out/cost_subcents to BIGINT would prove nothing about this +// column. This test seeds the rollup near the int32 ceiling via the real +// IncrementTaskSessionUsageTx path, then commits a further ledger event +// through CreateTaskUsageEvent (the insert+rollup transaction AC-11 +// requires) whose tokens_out delta crosses the boundary, and asserts that +// transaction commits with the correct summed value. +func TestPostgresCreateTaskUsageEvent_TokensOutCrossesInt32ThroughRealWriter(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + seedPostgresTaskSession(t, repo, "task-out-overflow-pg", "session-out-overflow-pg") + + const nearInt32Ceiling = int64(2_147_483_600) // int32 max is 2,147,483,647 + if err := repo.IncrementTaskSessionUsageTx( + context.Background(), nil, "session-out-overflow-pg", 0, 0, nearInt32Ceiling, 0, + ); err != nil { + t.Fatalf("seed rollup near int32 ceiling: %v", err) + } + + event := newTestUsageEvent("evt-out-overflow-pg", "task-out-overflow-pg", "session-out-overflow-pg") + crossingDelta := int64(200) + event.TokensOut = &crossingDelta + event.TokensTotal = event.TokensIn + crossingDelta + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("CreateTaskUsageEvent must not error when tokens_out crosses int32: %v", err) + } + + _, _, tokensOut, _ := readTaskSessionRollup(t, repo, "session-out-overflow-pg") + want := nearInt32Ceiling + crossingDelta + if tokensOut != want { + t.Errorf("tokens_out = %d, want %d (a delta crossing int32 must commit through the real writer)", + tokensOut, want) + } +} + +// TestPostgresCreateTaskUsageEvent_CostSubcentsCrossesInt32ThroughRealWriter +// is TestPostgresCreateTaskUsageEvent_TokensOutCrossesInt32ThroughRealWriter's +// twin for cost_subcents, closing the other half of the gap: the only +// existing beyond-int32 coverage for this column +// (TestPostgresTaskSessionsRollupColumns_LegacyIntegerSurvivesWidening) uses +// a raw UPDATE, not CreateTaskUsageEvent. +func TestPostgresCreateTaskUsageEvent_CostSubcentsCrossesInt32ThroughRealWriter(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + seedPostgresTaskSession(t, repo, "task-cost-overflow-pg", "session-cost-overflow-pg") + + const nearInt32Ceiling = int64(2_147_483_600) // int32 max is 2,147,483,647 + if err := repo.IncrementTaskSessionUsageTx( + context.Background(), nil, "session-cost-overflow-pg", 0, 0, 0, nearInt32Ceiling, + ); err != nil { + t.Fatalf("seed rollup near int32 ceiling: %v", err) + } + + event := newTestUsageEvent("evt-cost-overflow-pg", "task-cost-overflow-pg", "session-cost-overflow-pg") + event.CostSubcents = 200 + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("CreateTaskUsageEvent must not error when cost_subcents crosses int32: %v", err) + } + + _, _, _, costSubcents := readTaskSessionRollup(t, repo, "session-cost-overflow-pg") + want := nearInt32Ceiling + 200 + if costSubcents != want { + t.Errorf("cost_subcents = %d, want %d (a delta crossing int32 must commit through the real writer)", + costSubcents, want) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_events_schema.go b/apps/backend/internal/task/repository/sqlite/usage_events_schema.go new file mode 100644 index 0000000000..1acdc31306 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_events_schema.go @@ -0,0 +1,78 @@ +package sqlite + +import ( + "fmt" + + "github.com/kandev/kandev/internal/db/dialect" +) + +// initTaskUsageEventsSchema creates task_usage_events: one immutable row per +// observed agent usage event, carrying token counts, resolved dollar cost, +// and the inputs needed to re-derive that cost +// (docs/specs/task-cost-ledger/spec.md). This is a new table, not a column +// added to an existing one, so CREATE TABLE IF NOT EXISTS in the init block +// is correct and complete. +// +// usage_event_id's uniqueness is a NAMED unique index +// (uniq_task_usage_events_usage_event_id), not an inline column constraint, +// because AC-32's Postgres unique-violation detector matches on +// pgErr.ConstraintName - the same shape as uniq_tasks_external_id +// (task_external_id.go). +// +// task_id CASCADEs: task deletion is complete deletion of its spend record. +// session_id is ON DELETE SET NULL, mirroring task_step_transitions' +// rationale - the historical fact that this spend happened must survive a +// session being pruned. turn_id carries no FK for the same reason (AC-33, +// "Corrections to the task brief" #3). +// +// Every token/cost column is BIGINT, never INTEGER: a single turn in the +// spec's live-database sample reported 8,203,943 cached-read tokens, and a +// merged per-session cached-input total hit 98,805,109 - both already past +// Postgres's int4 ceiling. +func (r *Repository) initTaskUsageEventsSchema() error { + idCol := "id INTEGER PRIMARY KEY AUTOINCREMENT" + if dialect.IsPostgres(r.db.DriverName()) { + idCol = "id BIGSERIAL PRIMARY KEY" + } + _, err := r.db.Exec(` + CREATE TABLE IF NOT EXISTS task_usage_events ( + ` + idCol + `, + usage_event_id TEXT NOT NULL, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + session_id TEXT REFERENCES task_sessions(id) ON DELETE SET NULL, + turn_id TEXT, + agent_profile_id TEXT NOT NULL DEFAULT '', + agent_type TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + provider TEXT NOT NULL DEFAULT '', + tokens_in BIGINT NOT NULL DEFAULT 0, + tokens_cached_read BIGINT, + tokens_cached_write BIGINT, + tokens_out BIGINT, + tokens_thought BIGINT, + tokens_total BIGINT NOT NULL DEFAULT 0, + cost_subcents BIGINT NOT NULL DEFAULT 0, + cost_source TEXT NOT NULL, + estimated INTEGER NOT NULL DEFAULT 0, + rate_input_per_million BIGINT, + rate_cached_read_per_million BIGINT, + rate_cached_write_per_million BIGINT, + rate_output_per_million BIGINT, + pricing_catalog_version TEXT, + contract_version INTEGER NOT NULL, + occurred_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS uniq_task_usage_events_usage_event_id + ON task_usage_events(usage_event_id); + CREATE INDEX IF NOT EXISTS idx_task_usage_events_task + ON task_usage_events(task_id, occurred_at, id); + CREATE INDEX IF NOT EXISTS idx_task_usage_events_session_turn + ON task_usage_events(session_id, turn_id); + DROP INDEX IF EXISTS idx_task_usage_events_occurred; + `) + if err != nil { + return fmt.Errorf("init task usage events schema: %w", err) + } + return nil +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_events_schema_test.go b/apps/backend/internal/task/repository/sqlite/usage_events_schema_test.go new file mode 100644 index 0000000000..1cf213176b --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_events_schema_test.go @@ -0,0 +1,275 @@ +package sqlite + +// Schema-level coverage for task_usage_events (docs/specs/task-cost-ledger/spec.md): +// table shape, the named unique index AC-32's detector matches on, and the two +// FK behaviors that make this table an append-only durable record rather than +// a table Cascade quietly loses attribution from. + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + + dbutil "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/task/models" +) + +func newUsageEventsTestRepo(t *testing.T) *Repository { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "usage-events.db") + dbConn, err := dbutil.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("initialize schema: %v", err) + } + return repo +} + +func createUsageEventsTestTask(t *testing.T, repo *Repository, taskID string) *models.Task { + t.Helper() + task := &models.Task{ + ID: taskID, + WorkspaceID: "ws-1", + Title: "Usage events test task", + Priority: "medium", + } + if err := repo.CreateTask(context.Background(), task); err != nil { + t.Fatalf("CreateTask: %v", err) + } + return task +} + +func createUsageEventsTestSession(t *testing.T, repo *Repository, sessionID, taskID string) { + t.Helper() + if err := repo.CreateTaskSession(context.Background(), &models.TaskSession{ + ID: sessionID, TaskID: taskID, State: models.TaskSessionStateWaitingForInput, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } +} + +func insertUsageEventRow(t *testing.T, repo *Repository, eventID, taskID, sessionID string) { + t.Helper() + now := time.Now().UTC() + var sessionArg interface{} + if sessionID != "" { + sessionArg = sessionID + } + _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_usage_events ( + usage_event_id, task_id, session_id, turn_id, + agent_profile_id, agent_type, model, provider, + tokens_in, tokens_cached_read, tokens_cached_write, tokens_out, tokens_thought, + tokens_total, cost_subcents, cost_source, estimated, + contract_version, occurred_at, created_at + ) VALUES (?, ?, ?, NULL, '', '', '', '', 0, 0, 0, 0, 0, 0, 0, 'unpriced', 0, 1, ?, ?) + `), eventID, taskID, sessionArg, now, now) + if err != nil { + t.Fatalf("insert task_usage_events row %s: %v", eventID, err) + } +} + +// TestTaskUsageEventsSchema_FreshDB proves the table, its named unique index, +// and its three supporting indexes exist on a fresh database. +func TestTaskUsageEventsSchema_FreshDB(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-1") + + insertUsageEventRow(t, repo, "evt-1", "task-1", "") + + var count int + if err := repo.db.Get(&count, `SELECT COUNT(*) FROM task_usage_events`); err != nil { + t.Fatalf("count rows: %v", err) + } + if count != 1 { + t.Fatalf("row count = %d, want 1", count) + } + + var indexNames []string + if err := repo.db.Select(&indexNames, ` + SELECT name FROM sqlite_master + WHERE type = 'index' AND tbl_name = 'task_usage_events' + `); err != nil { + t.Fatalf("list indexes: %v", err) + } + want := map[string]bool{ + "uniq_task_usage_events_usage_event_id": false, + "idx_task_usage_events_task": false, + "idx_task_usage_events_session_turn": false, + } + for _, name := range indexNames { + if name == "idx_task_usage_events_occurred" { + t.Errorf("unexpected standalone occurred_at index %s, got indexes %v", name, indexNames) + } + if _, ok := want[name]; ok { + want[name] = true + } + } + for name, found := range want { + if !found { + t.Errorf("expected index %s to exist, got indexes %v", name, indexNames) + } + } +} + +// TestTaskUsageEventsSchema_Replay proves re-running schema init against an +// already-initialized database (a restart) does not error - CREATE TABLE IF +// NOT EXISTS / CREATE INDEX IF NOT EXISTS must both be idempotent - and that +// a usage event written before the replay, along with the rollup it drove, +// survives the replay untouched, and that the ledger still accepts new +// writes afterward (docs/specs/task-cost-ledger/spec.md AC-29, AC-36 row 3). +func TestTaskUsageEventsSchema_Replay(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "usage-events-replay.db") + dbConn, err := dbutil.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("first schema init: %v", err) + } + createUsageEventsTestTask(t, repo, "task-replay") + createUsageEventsTestSession(t, repo, "session-replay", "task-replay") + preReplayEvent := newTestUsageEvent("evt-replay-pre", "task-replay", "session-replay") + if err := repo.CreateTaskUsageEvent(context.Background(), preReplayEvent); err != nil { + t.Fatalf("CreateTaskUsageEvent before replay: %v", err) + } + + wantRows := countTaskUsageEventRows(t, repo) + wantTokensIn, wantTokensCachedIn, wantTokensOut, wantCostSubcents := readTaskSessionRollup(t, repo, "session-replay") + + repo, err = NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("replayed schema init must be a no-op, got error: %v", err) + } + + if got := countTaskUsageEventRows(t, repo); got != wantRows { + t.Errorf("row count after replay = %d, want %d (replay must not touch existing rows)", got, wantRows) + } + tokensIn, tokensCachedIn, tokensOut, costSubcents := readTaskSessionRollup(t, repo, "session-replay") + if tokensIn != wantTokensIn || tokensCachedIn != wantTokensCachedIn || tokensOut != wantTokensOut || costSubcents != wantCostSubcents { + t.Errorf("rollup after replay = (%d,%d,%d,%d), want (%d,%d,%d,%d) (replay must not touch the existing rollup)", + tokensIn, tokensCachedIn, tokensOut, costSubcents, wantTokensIn, wantTokensCachedIn, wantTokensOut, wantCostSubcents) + } + + postReplayEvent := newTestUsageEvent("evt-replay-post", "task-replay", "session-replay") + if err := repo.CreateTaskUsageEvent(context.Background(), postReplayEvent); err != nil { + t.Fatalf("CreateTaskUsageEvent after replay: %v", err) + } + if got := countTaskUsageEventRows(t, repo); got != wantRows+1 { + t.Errorf("row count after post-replay write = %d, want %d (the ledger must still accept writes after replay)", got, wantRows+1) + } + tokensIn, tokensCachedIn, tokensOut, costSubcents = readTaskSessionRollup(t, repo, "session-replay") + if tokensIn != wantTokensIn*2 || tokensCachedIn != wantTokensCachedIn*2 || tokensOut != wantTokensOut*2 || costSubcents != wantCostSubcents*2 { + t.Errorf("rollup after post-replay write = (%d,%d,%d,%d), want (%d,%d,%d,%d) (the post-replay write must still increment the rollup)", + tokensIn, tokensCachedIn, tokensOut, costSubcents, wantTokensIn*2, wantTokensCachedIn*2, wantTokensOut*2, wantCostSubcents*2) + } +} + +// TestTaskUsageEventsSchema_UniqueIndexRejectsDuplicateUsageEventID proves the +// named unique index actually enforces uniqueness at insert - AC-32's +// unique-violation detector depends on this constraint existing under this +// exact name. +func TestTaskUsageEventsSchema_UniqueIndexRejectsDuplicateUsageEventID(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-1") + + insertUsageEventRow(t, repo, "evt-dup", "task-1", "") + + now := time.Now().UTC() + _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_usage_events ( + usage_event_id, task_id, session_id, turn_id, + agent_profile_id, agent_type, model, provider, + tokens_in, tokens_cached_read, tokens_cached_write, tokens_out, tokens_thought, + tokens_total, cost_subcents, cost_source, estimated, + contract_version, occurred_at, created_at + ) VALUES (?, ?, ?, ?, '', '', '', '', 0, 0, 0, 0, 0, 0, 0, 'unpriced', 0, 1, ?, ?) + `), "evt-dup", "task-1", nil, nil, now, now) + if err == nil { + t.Fatal("expected a unique-constraint error inserting a duplicate usage_event_id, got nil") + } +} + +// TestTaskUsageEventsSchema_TaskDeleteCascades proves task_id CASCADEs: task +// deletion is complete deletion of its spend record. +func TestTaskUsageEventsSchema_TaskDeleteCascades(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-cascade") + insertUsageEventRow(t, repo, "evt-cascade", "task-cascade", "") + + if err := repo.DeleteTask(context.Background(), "task-cascade"); err != nil { + t.Fatalf("DeleteTask: %v", err) + } + + var count int + if err := repo.db.Get(&count, `SELECT COUNT(*) FROM task_usage_events WHERE task_id = ?`, "task-cascade"); err != nil { + t.Fatalf("count rows: %v", err) + } + if count != 0 { + t.Errorf("row count after task deletion = %d, want 0 (task_id must CASCADE)", count) + } +} + +// TestTaskUsageEventsSchema_SessionDeleteSetsNull proves session_id is +// ON DELETE SET NULL: the historical fact that this spend happened must +// survive a session being pruned, mirroring task_step_transitions' +// rationale. +func TestTaskUsageEventsSchema_SessionDeleteSetsNull(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-setnull") + createUsageEventsTestSession(t, repo, "session-setnull", "task-setnull") + insertUsageEventRow(t, repo, "evt-setnull", "task-setnull", "session-setnull") + + if err := repo.DeleteTaskSession(context.Background(), "session-setnull"); err != nil { + t.Fatalf("DeleteTaskSession: %v", err) + } + + var sessionID *string + if err := repo.db.Get(&sessionID, `SELECT session_id FROM task_usage_events WHERE usage_event_id = ?`, "evt-setnull"); err != nil { + t.Fatalf("read row: %v", err) + } + if sessionID != nil { + t.Errorf("session_id after session deletion = %v, want nil (row must survive with session_id cleared)", *sessionID) + } + + var count int + if err := repo.db.Get(&count, `SELECT COUNT(*) FROM task_usage_events WHERE usage_event_id = ?`, "evt-setnull"); err != nil { + t.Fatalf("count rows: %v", err) + } + if count != 1 { + t.Errorf("row count after session deletion = %d, want 1 (row must be retained)", count) + } +} + +// TestTaskUsageEventsSchema_TaskForeignKeyRejectsUnknownTask proves task_id's +// FK is enforced: an insert naming a task that doesn't exist is rejected +// outright rather than committing, which is what makes AC-32's foreign-key +// classification reachable. +func TestTaskUsageEventsSchema_TaskForeignKeyRejectsUnknownTask(t *testing.T) { + repo := newUsageEventsTestRepo(t) + now := time.Now().UTC() + _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_usage_events ( + usage_event_id, task_id, session_id, turn_id, + agent_profile_id, agent_type, model, provider, + tokens_in, tokens_cached_read, tokens_cached_write, tokens_out, tokens_thought, + tokens_total, cost_subcents, cost_source, estimated, + contract_version, occurred_at, created_at + ) VALUES (?, ?, ?, ?, '', '', '', '', 0, 0, 0, 0, 0, 0, 0, 'unpriced', 0, 1, ?, ?) + `), "evt-no-task", "task-does-not-exist", nil, nil, now, now) + if err == nil { + t.Fatal("expected a foreign-key error inserting a row against an unknown task_id, got nil") + } +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_events_shared_tx_test.go b/apps/backend/internal/task/repository/sqlite/usage_events_shared_tx_test.go new file mode 100644 index 0000000000..94a743f59c --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_events_shared_tx_test.go @@ -0,0 +1,83 @@ +package sqlite + +// TestUsageEventInsertAndRollupShareOneTransaction is the repointed +// replacement (docs/specs/task-cost-ledger/spec.md AC-21, AC-36) for the +// deleted office/repository/sqlite test +// TestCostEventTxAtomicity_RealReposShareTransaction. That test drove +// office.Repository.BeginTx -> office.Repository.CreateCostEventTx -> +// task.Repository.IncrementTaskSessionUsageTx to prove the transaction +// office's recordCostEventAndRollup began could really reach task_sessions, +// a table the task package owns - not just a test double that discarded its +// *sqlx.Tx parameter (PR #2606 review round 1, F2). +// +// AC-21 removes that caller: recordCostEventAndRollup no longer begins a +// shared transaction at all. IncrementTaskSessionUsageTx is retained because +// the ledger writer's own insertUsageEventAndRollup (usage_events.go) is now +// the real caller of this exact capability, so this test repoints the same +// rollback-then-commit shape at the ledger writer's own two writes - +// insertUsageEventRowTx and IncrementTaskSessionUsageTx - sharing one +// *sqlx.Tx, driven manually rather than through CreateTaskUsageEvent so the +// tx != nil branch is exercised explicitly, the same guarantee the original +// test existed to pin. + +import ( + "context" + "testing" +) + +func TestUsageEventInsertAndRollupShareOneTransaction(t *testing.T) { + repo := newUsageEventsTestRepo(t) + ctx := context.Background() + createUsageEventsTestTask(t, repo, "task-shared-tx") + createUsageEventsTestSession(t, repo, "session-shared-tx", "task-shared-tx") + + // Rollback: both writes land in the same transaction; rolling it back + // must undo both, not just the one whose caller happened to fail. + rollbackEvent := newTestUsageEvent("evt-shared-tx-rollback", "task-shared-tx", "session-shared-tx") + tx, err := repo.db.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("begin tx: %v", err) + } + if err := repo.insertUsageEventRowTx(ctx, tx, rollbackEvent); err != nil { + t.Fatalf("insert usage event row in tx: %v", err) + } + if err := repo.IncrementTaskSessionUsageTx(ctx, tx, "session-shared-tx", 100, 25, 30, 42); err != nil { + t.Fatalf("increment rollup in tx: %v", err) + } + if err := tx.Rollback(); err != nil { + t.Fatalf("rollback: %v", err) + } + + if got := countTaskUsageEventRows(t, repo); got != 0 { + t.Fatalf("row count after rollback = %d, want 0", got) + } + tokensIn, tokensCachedIn, tokensOut, costSubcents := readTaskSessionRollup(t, repo, "session-shared-tx") + if tokensIn != 0 || tokensCachedIn != 0 || tokensOut != 0 || costSubcents != 0 { + t.Fatalf("rollup after rollback = (%d,%d,%d,%d), want all zero", tokensIn, tokensCachedIn, tokensOut, costSubcents) + } + + // Commit: a fresh transaction with the same shape commits both writes + // together. + commitEvent := newTestUsageEvent("evt-shared-tx-commit", "task-shared-tx", "session-shared-tx") + tx2, err := repo.db.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("begin tx (commit path): %v", err) + } + if err := repo.insertUsageEventRowTx(ctx, tx2, commitEvent); err != nil { + t.Fatalf("insert usage event row in tx (commit path): %v", err) + } + if err := repo.IncrementTaskSessionUsageTx(ctx, tx2, "session-shared-tx", 100, 25, 30, 42); err != nil { + t.Fatalf("increment rollup in tx (commit path): %v", err) + } + if err := tx2.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + + if got := countTaskUsageEventRows(t, repo); got != 1 { + t.Fatalf("row count after commit = %d, want 1", got) + } + tokensIn, tokensCachedIn, tokensOut, costSubcents = readTaskSessionRollup(t, repo, "session-shared-tx") + if tokensIn != 100 || tokensCachedIn != 25 || tokensOut != 30 || costSubcents != 42 { + t.Fatalf("rollup after commit = (%d,%d,%d,%d), want (100,25,30,42)", tokensIn, tokensCachedIn, tokensOut, costSubcents) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_events_test.go b/apps/backend/internal/task/repository/sqlite/usage_events_test.go new file mode 100644 index 0000000000..ead647099a --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_events_test.go @@ -0,0 +1,381 @@ +package sqlite + +// Behavioral coverage for CreateTaskUsageEvent (docs/specs/task-cost-ledger/spec.md +// AC-11, AC-12, AC-13, AC-14, AC-30, AC-32, AC-33): the atomic ledger-insert + +// rollup transaction and its insert-failure classification/reaction. + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func newTestUsageEvent(usageEventID, taskID, sessionID string) *models.TaskUsageEvent { + now := time.Now().UTC() + tokensCachedRead := int64(20) + tokensCachedWrite := int64(5) + tokensOut := int64(30) + return &models.TaskUsageEvent{ + UsageEventID: usageEventID, + TaskID: taskID, + SessionID: sessionID, + AgentType: "claude", + Model: "claude-sonnet-5", + Provider: "anthropic", + TokensIn: 100, + TokensCachedRead: &tokensCachedRead, + TokensCachedWrite: &tokensCachedWrite, + TokensOut: &tokensOut, + TokensTotal: 155, + CostSubcents: 42, + CostSource: "actual", + ContractVersion: 1, + OccurredAt: now, + CreatedAt: now, + } +} + +func readTaskSessionRollup(t *testing.T, repo *Repository, sessionID string) (tokensIn, tokensCachedIn, tokensOut, costSubcents int64) { + t.Helper() + if err := repo.db.QueryRowx(repo.db.Rebind(` + SELECT COALESCE(tokens_in, 0), COALESCE(tokens_cached_in, 0), COALESCE(tokens_out, 0), COALESCE(cost_subcents, 0) + FROM task_sessions WHERE id = ? + `), sessionID).Scan(&tokensIn, &tokensCachedIn, &tokensOut, &costSubcents); err != nil { + t.Fatalf("read task_sessions rollup: %v", err) + } + return +} + +func countTaskUsageEventRows(t *testing.T, repo *Repository) int { + t.Helper() + var count int + if err := repo.db.Get(&count, `SELECT COUNT(*) FROM task_usage_events`); err != nil { + t.Fatalf("count rows: %v", err) + } + return count +} + +func TestCreateTaskUsageEvent_HappyPath_InsertsRowAndIncrementsRollup(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-happy") + createUsageEventsTestSession(t, repo, "session-happy", "task-happy") + + event := newTestUsageEvent("evt-happy", "task-happy", "session-happy") + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("CreateTaskUsageEvent: %v", err) + } + + if got := countTaskUsageEventRows(t, repo); got != 1 { + t.Fatalf("row count = %d, want 1", got) + } + tokensIn, tokensCachedIn, tokensOut, costSubcents := readTaskSessionRollup(t, repo, "session-happy") + if tokensIn != 100 || tokensCachedIn != 25 || tokensOut != 30 || costSubcents != 42 { + t.Errorf("rollup = (%d,%d,%d,%d), want (100,25,30,42)", tokensIn, tokensCachedIn, tokensOut, costSubcents) + } +} + +// TestCreateTaskUsageEvent_SameSessionAndTurn_BothRowsCommitAndRollupSums +// pins AC-15: (session_id, turn_id) is an aggregation key for reads, not a +// uniqueness constraint on writes - a single turn producing multiple ledger +// rows (e.g. more than one prompt-usage event in one turn) must not be +// rejected or collapsed. +func TestCreateTaskUsageEvent_SameSessionAndTurn_BothRowsCommitAndRollupSums(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-same-turn") + createUsageEventsTestSession(t, repo, "session-same-turn", "task-same-turn") + + first := newTestUsageEvent("evt-same-turn-1", "task-same-turn", "session-same-turn") + first.TurnID = "turn-shared" + if err := repo.CreateTaskUsageEvent(context.Background(), first); err != nil { + t.Fatalf("CreateTaskUsageEvent (first): %v", err) + } + + second := newTestUsageEvent("evt-same-turn-2", "task-same-turn", "session-same-turn") + second.TurnID = "turn-shared" + if err := repo.CreateTaskUsageEvent(context.Background(), second); err != nil { + t.Fatalf("CreateTaskUsageEvent (second, same session_id+turn_id): %v", err) + } + + if got := countTaskUsageEventRows(t, repo); got != 2 { + t.Fatalf("row count = %d, want 2 (both rows for the shared turn must commit)", got) + } + + events, err := repo.ListTaskUsageEvents(context.Background(), "task-same-turn", 0) + if err != nil { + t.Fatalf("ListTaskUsageEvents: %v", err) + } + if len(events) != 2 { + t.Fatalf("ListTaskUsageEvents returned %d events, want 2", len(events)) + } + if events[0].TurnID != "turn-shared" || events[1].TurnID != "turn-shared" { + t.Errorf("turn IDs = [%q, %q], want both rows to carry turn_id=turn-shared", events[0].TurnID, events[1].TurnID) + } + if events[0].UsageEventID == events[1].UsageEventID { + t.Errorf("both rows have usage_event_id %q, want two distinct rows", events[0].UsageEventID) + } + + tokensIn, tokensCachedIn, tokensOut, costSubcents := readTaskSessionRollup(t, repo, "session-same-turn") + if tokensIn != 200 || tokensCachedIn != 50 || tokensOut != 60 || costSubcents != 84 { + t.Errorf("rollup = (%d,%d,%d,%d), want (200,50,60,84) (both rows must contribute)", tokensIn, tokensCachedIn, tokensOut, costSubcents) + } +} + +// TestCreateTaskUsageEvent_NilTokenPointersCoalesceToZeroInRollup pins AC-12: +// a not-recorded token count (nil pointer) contributes zero to the rollup +// rather than propagating SQL NULL through the sum. +func TestCreateTaskUsageEvent_NilTokenPointersCoalesceToZeroInRollup(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-nil-tokens") + createUsageEventsTestSession(t, repo, "session-nil-tokens", "task-nil-tokens") + + event := newTestUsageEvent("evt-nil-tokens", "task-nil-tokens", "session-nil-tokens") + event.TokensCachedRead = nil + event.TokensCachedWrite = nil + event.TokensOut = nil + + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("CreateTaskUsageEvent: %v", err) + } + + tokensIn, tokensCachedIn, tokensOut, costSubcents := readTaskSessionRollup(t, repo, "session-nil-tokens") + if tokensIn != 100 || tokensCachedIn != 0 || tokensOut != 0 || costSubcents != 42 { + t.Errorf("rollup = (%d,%d,%d,%d), want (100,0,0,42)", tokensIn, tokensCachedIn, tokensOut, costSubcents) + } +} + +// TestCreateTaskUsageEvent_DuplicateUsageEventID_ReturnsErrDuplicateNoRollup +// pins AC-13/AC-14/AC-32: a redelivered usage_event_id records no new row +// and increments the rollup no further, reported as a duplicate rather than +// an error. +func TestCreateTaskUsageEvent_DuplicateUsageEventID_ReturnsErrDuplicateNoRollup(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-dup") + createUsageEventsTestSession(t, repo, "session-dup", "task-dup") + + first := newTestUsageEvent("evt-dup", "task-dup", "session-dup") + if err := repo.CreateTaskUsageEvent(context.Background(), first); err != nil { + t.Fatalf("first CreateTaskUsageEvent: %v", err) + } + + redelivered := newTestUsageEvent("evt-dup", "task-dup", "session-dup") + err := repo.CreateTaskUsageEvent(context.Background(), redelivered) + if err != ErrDuplicateUsageEvent { + t.Fatalf("redelivered CreateTaskUsageEvent error = %v, want ErrDuplicateUsageEvent", err) + } + + if got := countTaskUsageEventRows(t, repo); got != 1 { + t.Fatalf("row count = %d, want 1 (redelivery must not insert a second row)", got) + } + tokensIn, _, _, _ := readTaskSessionRollup(t, repo, "session-dup") + if tokensIn != 100 { + t.Errorf("tokens_in = %d, want 100 (redelivery must not double-increment the rollup)", tokensIn) + } +} + +// TestCreateTaskUsageEvent_ConcurrentInsertsSameUsageEventID_ExactlyOneCommits +// pins AC-32's unique-violation branch under a genuine race, not a +// sequential redelivery: two deliveries of the same event, racing each +// other, with the producer's deterministic identifier (spec's "Concurrency" +// scenario). Both goroutines are released together via a start barrier so +// they race for real; SQLITE_BUSY contention is expected and handled by +// CreateTaskUsageEvent's own transient-retry loop, not by this test. +// Exactly one call must succeed and the other must observe +// ErrDuplicateUsageEvent - no other error, and no silent double-drop - with +// the rollup incremented exactly once, matching the loser's increment +// sharing the loser's rolled-back transaction. +func TestCreateTaskUsageEvent_ConcurrentInsertsSameUsageEventID_ExactlyOneCommits(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-race") + createUsageEventsTestSession(t, repo, "session-race", "task-race") + + start := make(chan struct{}) + var wg sync.WaitGroup + errs := make([]error, 2) + for i := range errs { + wg.Add(1) + go func(i int) { + defer wg.Done() + event := newTestUsageEvent("evt-race", "task-race", "session-race") + <-start + errs[i] = repo.CreateTaskUsageEvent(context.Background(), event) + }(i) + } + close(start) + wg.Wait() + + successes, duplicates := 0, 0 + for _, err := range errs { + switch err { + case nil: + successes++ + case ErrDuplicateUsageEvent: + duplicates++ + default: + t.Fatalf("unexpected error from a racing insert: %v", err) + } + } + if successes != 1 || duplicates != 1 { + t.Fatalf("successes=%d duplicates=%d, want exactly one of each", successes, duplicates) + } + + if got := countTaskUsageEventRows(t, repo); got != 1 { + t.Fatalf("row count = %d, want 1 (the losing insert must not leave a row behind)", got) + } + tokensIn, _, _, _ := readTaskSessionRollup(t, repo, "session-race") + if tokensIn != 100 { + t.Errorf("tokens_in = %d, want 100 (the rollup must be incremented exactly once, not twice or zero times)", tokensIn) + } +} + +// TestCreateTaskUsageEvent_SessionForeignKeyViolation_RetriesOnceWithSessionCleared +// pins AC-32(b): an insert naming a session that does not exist is retried +// exactly once with session_id cleared, and that retry does not increment +// any session's rollup. +func TestCreateTaskUsageEvent_SessionForeignKeyViolation_RetriesOnceWithSessionCleared(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-fk-retry") + + event := newTestUsageEvent("evt-fk-retry", "task-fk-retry", "session-does-not-exist") + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("CreateTaskUsageEvent: %v", err) + } + + if got := countTaskUsageEventRows(t, repo); got != 1 { + t.Fatalf("row count = %d, want 1", got) + } + var sessionID *string + if err := repo.db.Get(&sessionID, `SELECT session_id FROM task_usage_events WHERE usage_event_id = ?`, "evt-fk-retry"); err != nil { + t.Fatalf("read row: %v", err) + } + if sessionID != nil { + t.Errorf("session_id = %v, want nil (FK retry must clear it)", *sessionID) + } +} + +// TestCreateTaskUsageEvent_SecondForeignKeyFailure_ReturnsError pins AC-32(b)'s +// tail: a second failure after the one allotted FK retry is returned as-is, +// not retried again. +func TestCreateTaskUsageEvent_SecondForeignKeyFailure_ReturnsError(t *testing.T) { + repo := newUsageEventsTestRepo(t) + + event := newTestUsageEvent("evt-fk-double", "task-does-not-exist", "session-does-not-exist") + err := repo.CreateTaskUsageEvent(context.Background(), event) + if err == nil { + t.Fatal("expected an error when both task_id and session_id are invalid, got nil") + } + if err == ErrDuplicateUsageEvent { + t.Fatalf("error = ErrDuplicateUsageEvent, want a foreign-key failure") + } + if got := countTaskUsageEventRows(t, repo); got != 0 { + t.Errorf("row count = %d, want 0 (both attempts must roll back)", got) + } +} + +// TestCreateTaskUsageEvent_SessionBelongsToDifferentTask_ClearsSessionNoRollup +// pins AC-33: a session that exists but belongs to a different task is +// recorded under the payload's task_id with session_id cleared, the same +// terminal handling as the foreign-key retry case. +func TestCreateTaskUsageEvent_SessionBelongsToDifferentTask_ClearsSessionNoRollup(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-owner") + createUsageEventsTestSession(t, repo, "session-owner", "task-owner") + createUsageEventsTestTask(t, repo, "task-other") + + event := newTestUsageEvent("evt-mismatch", "task-other", "session-owner") + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("CreateTaskUsageEvent: %v", err) + } + + var gotTaskID string + var sessionID *string + if err := repo.db.QueryRowx(`SELECT task_id, session_id FROM task_usage_events WHERE usage_event_id = ?`, "evt-mismatch"). + Scan(&gotTaskID, &sessionID); err != nil { + t.Fatalf("read row: %v", err) + } + if gotTaskID != "task-other" { + t.Errorf("task_id = %q, want %q", gotTaskID, "task-other") + } + if sessionID != nil { + t.Errorf("session_id = %v, want nil (mismatched session must be cleared)", *sessionID) + } + + tokensIn, _, _, _ := readTaskSessionRollup(t, repo, "session-owner") + if tokensIn != 0 { + t.Errorf("session-owner tokens_in = %d, want 0 (mismatched session must not be rolled up into)", tokensIn) + } +} + +// TestCreateTaskUsageEvent_SessionOwnershipLookupHardError_ReturnsImmediately +// pins R5-F1: a session-ownership lookup failure that is not "no such +// session" is returned immediately, without attempting any insert. +func TestCreateTaskUsageEvent_SessionOwnershipLookupHardError_ReturnsImmediately(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-hard-error") + if _, err := repo.db.Exec(`DROP TABLE task_sessions`); err != nil { + t.Fatalf("drop task_sessions to force a hard ownership-lookup error: %v", err) + } + + event := newTestUsageEvent("evt-hard-error", "task-hard-error", "session-whatever") + err := repo.CreateTaskUsageEvent(context.Background(), event) + if err == nil { + t.Fatal("expected an error when the ownership lookup itself fails, got nil") + } + if err == ErrDuplicateUsageEvent { + t.Fatalf("error = ErrDuplicateUsageEvent, want the raw ownership-lookup error") + } + + var count int + if err := repo.db.Get(&count, `SELECT COUNT(*) FROM task_usage_events`); err != nil { + t.Fatalf("count rows: %v", err) + } + if count != 0 { + t.Errorf("row count = %d, want 0 (a hard ownership-lookup error must not attempt an insert)", count) + } +} + +// TestCreateTaskUsageEvent_NullableFieldsRoundTripThroughList pins AC-30: a +// nil token pointer round-trips as nil (not a measured zero), and a +// measured-zero pointer round-trips distinctly as a non-nil zero. +func TestCreateTaskUsageEvent_NullableFieldsRoundTripThroughList(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-roundtrip") + createUsageEventsTestSession(t, repo, "session-roundtrip", "task-roundtrip") + + event := newTestUsageEvent("evt-roundtrip", "task-roundtrip", "session-roundtrip") + measuredZero := int64(0) + event.TokensThought = nil + event.TokensOut = &measuredZero + event.PricingCatalogVersion = "catalog-v3" + event.TurnID = "turn-1" + + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("CreateTaskUsageEvent: %v", err) + } + + events, err := repo.ListTaskUsageEvents(context.Background(), "task-roundtrip", 0) + if err != nil { + t.Fatalf("ListTaskUsageEvents: %v", err) + } + if len(events) != 1 { + t.Fatalf("len(events) = %d, want 1", len(events)) + } + got := events[0] + if got.TokensThought != nil { + t.Errorf("TokensThought = %v, want nil (not recorded)", *got.TokensThought) + } + if got.TokensOut == nil || *got.TokensOut != 0 { + t.Errorf("TokensOut = %v, want a non-nil measured zero", got.TokensOut) + } + if got.PricingCatalogVersion != "catalog-v3" { + t.Errorf("PricingCatalogVersion = %q, want %q", got.PricingCatalogVersion, "catalog-v3") + } + if got.TurnID != "turn-1" { + t.Errorf("TurnID = %q, want %q", got.TurnID, "turn-1") + } + if got.SessionID != "session-roundtrip" { + t.Errorf("SessionID = %q, want %q", got.SessionID, "session-roundtrip") + } +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_events_transient_test.go b/apps/backend/internal/task/repository/sqlite/usage_events_transient_test.go new file mode 100644 index 0000000000..d0df7f29ed --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_events_transient_test.go @@ -0,0 +1,133 @@ +package sqlite + +// Transient-error retry sequencing for CreateTaskUsageEvent (AC-32(c)): a +// transient failure is retried in a fresh transaction, waiting per +// usageEventTransientBackoffs, up to maxUsageEventTransientAttempts total +// attempts. A genuine SQLITE_BUSY/serialization race cannot be triggered +// deterministically against this package's single-connection test +// repositories, so these tests drive the retry loop through the failpoint +// seams in base.go instead. They run inside synctest.Test so +// usageEventTransientBackoffs' real time.Timer waits (sleepOrDone) advance on +// the fake clock instead of costing real wall time. +// +// TestCreateTaskUsageEvent_TransientError_RetriesAndSucceeds and +// TestCreateTaskUsageEvent_TransientErrorExhaustsRetries_ReturnsError use +// failUsageEventRollupAttempts/failUsageEventRollupErr, which fires after +// insertUsageEventRowTx has actually attempted the row insert on each +// retried attempt - proving the retry loop rolls back and re-attempts a real +// insert, not just a pre-transaction error return. +// TestCreateTaskUsageEvent_TransientErrorDoesNotConsumeForeignKeyRetryBudget +// keeps the older failUsageEventAttempts/failUsageEventErr seam (fires +// before BeginTxx) because its scenario depends on the injected transient +// failure preceding a real foreign-key violation on the same attempt; the +// FK-violating session ("session-does-not-exist") is never created, so a +// post-insert failpoint would never fire on that attempt at all. + +import ( + "context" + "errors" + "testing" + "testing/synctest" + "time" + + "github.com/mattn/go-sqlite3" +) + +func transientSQLiteBusyErr() error { + return sqlite3.Error{Code: sqlite3.ErrBusy} +} + +// TestCreateTaskUsageEvent_TransientError_RetriesAndSucceeds pins that a +// single transient failure is retried (not surfaced as an error), and that +// the retry actually inserts the row. +func TestCreateTaskUsageEvent_TransientError_RetriesAndSucceeds(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-transient-retry") + createUsageEventsTestSession(t, repo, "session-transient-retry", "task-transient-retry") + + repo.failUsageEventRollupAttempts = 1 + repo.failUsageEventRollupErr = transientSQLiteBusyErr() + + event := newTestUsageEvent("evt-transient-retry", "task-transient-retry", "session-transient-retry") + start := time.Now() + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("CreateTaskUsageEvent: %v", err) + } + elapsed := time.Since(start) + + if elapsed < usageEventTransientBackoffs[0] { + t.Errorf("elapsed = %v, want at least the first backoff (%v)", elapsed, usageEventTransientBackoffs[0]) + } + if got := countTaskUsageEventRows(t, repo); got != 1 { + t.Fatalf("row count = %d, want 1", got) + } + }) +} + +// TestCreateTaskUsageEvent_TransientErrorExhaustsRetries_ReturnsError pins +// AC-32(c)'s cap: maxUsageEventTransientAttempts total attempts, after which +// the last transient error is returned as-is and no row is inserted. +func TestCreateTaskUsageEvent_TransientErrorExhaustsRetries_ReturnsError(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-transient-exhaust") + createUsageEventsTestSession(t, repo, "session-transient-exhaust", "task-transient-exhaust") + + repo.failUsageEventRollupAttempts = maxUsageEventTransientAttempts + repo.failUsageEventRollupErr = transientSQLiteBusyErr() + + event := newTestUsageEvent("evt-transient-exhaust", "task-transient-exhaust", "session-transient-exhaust") + start := time.Now() + err := repo.CreateTaskUsageEvent(context.Background(), event) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected an error after exhausting all transient-retry attempts, got nil") + } + if !errors.Is(err, repo.failUsageEventRollupErr) && err.Error() != transientSQLiteBusyErr().Error() { + t.Errorf("returned error = %v, want the injected transient error surfaced as-is", err) + } + + var wantMinWait time.Duration + for _, backoff := range usageEventTransientBackoffs { + wantMinWait += backoff + } + if elapsed < wantMinWait { + t.Errorf("elapsed = %v, want at least the sum of all backoffs (%v)", elapsed, wantMinWait) + } + if got := countTaskUsageEventRows(t, repo); got != 0 { + t.Errorf("row count = %d, want 0 (exhausted retries must not leave a row)", got) + } + }) +} + +// TestCreateTaskUsageEvent_TransientErrorDoesNotConsumeForeignKeyRetryBudget +// pins that the transient-retry budget and the one-shot foreign-key retry +// are independent: a transient failure followed by a real foreign-key +// violation still gets its single FK retry. +func TestCreateTaskUsageEvent_TransientErrorDoesNotConsumeForeignKeyRetryBudget(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-transient-then-fk") + + repo.failUsageEventAttempts = 1 + repo.failUsageEventErr = transientSQLiteBusyErr() + + event := newTestUsageEvent("evt-transient-then-fk", "task-transient-then-fk", "session-does-not-exist") + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("CreateTaskUsageEvent: %v", err) + } + + if got := countTaskUsageEventRows(t, repo); got != 1 { + t.Fatalf("row count = %d, want 1", got) + } + var sessionID *string + if err := repo.db.Get(&sessionID, `SELECT session_id FROM task_usage_events WHERE usage_event_id = ?`, "evt-transient-then-fk"); err != nil { + t.Fatalf("read row: %v", err) + } + if sessionID != nil { + t.Errorf("session_id = %v, want nil (the FK retry must still be available after a transient retry)", *sessionID) + } + }) +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_events_unique.go b/apps/backend/internal/task/repository/sqlite/usage_events_unique.go new file mode 100644 index 0000000000..d8773ce9f1 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_events_unique.go @@ -0,0 +1,52 @@ +package sqlite + +import ( + "errors" + "strings" + + "github.com/jackc/pgx/v5/pgconn" +) + +// ErrDuplicateUsageEvent is returned when an insert into task_usage_events +// collides with an existing row's usage_event_id. The real duplicate source +// is the same underlying usage frame reaching the ledger writer twice (e.g. +// a reconnecting WS client replaying a buffered stream event), not +// double-spend, so callers treat this as an idempotent no-op (AC-13, AC-14, +// AC-32) rather than a failure. Mirrors +// internal/office/repository/sqlite/costs.go's ErrDuplicateUsageEvent. +var ErrDuplicateUsageEvent = errors.New("duplicate usage_event_id") + +// usageEventIDIndexName is the named unique index enforcing at most one +// task_usage_events row per usage_event_id +// (docs/specs/task-cost-ledger/spec.md, "task_usage_events (new table)"). +// Naming it explicitly here keeps isUsageEventUniqueViolation attributable +// to this constraint specifically, not to the table's primary key or any +// other unique constraint. +const usageEventIDIndexName = "uniq_task_usage_events_usage_event_id" + +// sqliteUsageEventIDViolationMessage is the substring go-sqlite3 puts in a +// UNIQUE-constraint error for this index. SQLite exposes no typed access to +// the violated index's name, only the column list of the failing index +// ("UNIQUE constraint failed: task_usage_events.usage_event_id"); that +// column is unique to uniq_task_usage_events_usage_event_id in this table, +// so matching it attributes the violation correctly - a bare "UNIQUE +// constraint failed" match would also fire on the table's primary key. +const sqliteUsageEventIDViolationMessage = "UNIQUE constraint failed: task_usage_events.usage_event_id" + +// isUsageEventUniqueViolation reports whether err is a violation of +// uniq_task_usage_events_usage_event_id specifically, not any unique +// violation (AC-32). On PostgreSQL it inspects the typed pgconn.PgError's +// constraint name; on SQLite (no typed access to the constraint name) it +// matches the column-list message documented above. Mirrors +// isExternalIDUniqueViolation (task_external_id.go) and +// internal/office/repository/sqlite/costs.go's isUsageEventUniqueViolation. +func isUsageEventUniqueViolation(err error) bool { + if err == nil { + return false + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "23505" && pgErr.ConstraintName == usageEventIDIndexName + } + return strings.Contains(err.Error(), sqliteUsageEventIDViolationMessage) +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_events_unique_postgres_test.go b/apps/backend/internal/task/repository/sqlite/usage_events_unique_postgres_test.go new file mode 100644 index 0000000000..689888f724 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_events_unique_postgres_test.go @@ -0,0 +1,66 @@ +package sqlite + +// AC-32 coverage against a real Postgres instance: the detector's typed +// pgconn.PgError branch (ConstraintName == uniq_task_usage_events_usage_event_id) +// is only reachable through an actual PostgreSQL driver error, never through +// SQLite's string-matched fallback. +// +// Skips unless KANDEV_TEST_POSTGRES_DSN is set. + +import ( + "testing" + "time" + + "github.com/kandev/kandev/internal/testutil" +) + +func insertPostgresUsageEventRow(t *testing.T, repo *Repository, eventID, taskID string) error { + t.Helper() + now := time.Now().UTC() + _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_usage_events ( + usage_event_id, task_id, session_id, turn_id, + agent_profile_id, agent_type, model, provider, + tokens_in, tokens_cached_read, tokens_cached_write, tokens_out, tokens_thought, + tokens_total, cost_subcents, cost_source, estimated, + contract_version, occurred_at, created_at + ) VALUES (?, ?, ?, ?, '', '', '', '', 0, 0, 0, 0, 0, 0, 0, 'unpriced', 0, 1, ?, ?) + `), eventID, taskID, nil, nil, now, now) + return err +} + +func TestPostgresIsUsageEventUniqueViolation_DuplicateUsageEventID(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + seedPostgresTaskSession(t, repo, "task-dup-unique-pg", "session-dup-unique-pg") + + if err := insertPostgresUsageEventRow(t, repo, "evt-dup-pg", "task-dup-unique-pg"); err != nil { + t.Fatalf("first insert: %v", err) + } + err = insertPostgresUsageEventRow(t, repo, "evt-dup-pg", "task-dup-unique-pg") + if err == nil { + t.Fatal("expected a unique-constraint error inserting a duplicate usage_event_id, got nil") + } + if !isUsageEventUniqueViolation(err) { + t.Errorf("isUsageEventUniqueViolation(%v) = false, want true", err) + } +} + +func TestPostgresIsUsageEventUniqueViolation_ForeignKeyNotMisclassified(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + + err = insertPostgresUsageEventRow(t, repo, "evt-no-task-pg", "task-does-not-exist-pg") + if err == nil { + t.Fatal("expected a foreign-key error inserting a row against an unknown task_id, got nil") + } + if isUsageEventUniqueViolation(err) { + t.Errorf("isUsageEventUniqueViolation(%v) = true, want false (this is a foreign-key violation)", err) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_events_unique_test.go b/apps/backend/internal/task/repository/sqlite/usage_events_unique_test.go new file mode 100644 index 0000000000..4f02904a14 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_events_unique_test.go @@ -0,0 +1,103 @@ +package sqlite + +// Detector-level coverage for isUsageEventUniqueViolation (AC-32): it must +// fire only for uniq_task_usage_events_usage_event_id, not for the table's +// primary key or its foreign keys, so a non-duplicate insert failure is +// never silently swallowed as "expected" duplicate behaviour. + +import ( + "testing" + "time" +) + +func TestIsUsageEventUniqueViolation_Nil(t *testing.T) { + if isUsageEventUniqueViolation(nil) { + t.Error("isUsageEventUniqueViolation(nil) = true, want false") + } +} + +func TestIsUsageEventUniqueViolation_SQLiteDuplicateUsageEventID(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-1") + insertUsageEventRow(t, repo, "evt-dup", "task-1", "") + + now := time.Now().UTC() + _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_usage_events ( + usage_event_id, task_id, session_id, turn_id, + agent_profile_id, agent_type, model, provider, + tokens_in, tokens_cached_read, tokens_cached_write, tokens_out, tokens_thought, + tokens_total, cost_subcents, cost_source, estimated, + contract_version, occurred_at, created_at + ) VALUES (?, ?, ?, ?, '', '', '', '', 0, 0, 0, 0, 0, 0, 0, 'unpriced', 0, 1, ?, ?) + `), "evt-dup", "task-1", nil, nil, now, now) + if err == nil { + t.Fatal("expected a unique-constraint error inserting a duplicate usage_event_id, got nil") + } + if !isUsageEventUniqueViolation(err) { + t.Errorf("isUsageEventUniqueViolation(%v) = false, want true", err) + } +} + +// TestIsUsageEventUniqueViolation_SQLitePrimaryKeyConflictNotMisclassified +// proves the detector is constraint-specific: a duplicate on the table's own +// primary key must NOT be reported as a usage_event_id violation, even +// though both are "UNIQUE constraint failed" errors. +func TestIsUsageEventUniqueViolation_SQLitePrimaryKeyConflictNotMisclassified(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-1") + now := time.Now().UTC() + + _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_usage_events ( + id, usage_event_id, task_id, session_id, turn_id, + agent_profile_id, agent_type, model, provider, + tokens_in, tokens_cached_read, tokens_cached_write, tokens_out, tokens_thought, + tokens_total, cost_subcents, cost_source, estimated, + contract_version, occurred_at, created_at + ) VALUES (?, ?, ?, ?, ?, '', '', '', '', 0, 0, 0, 0, 0, 0, 0, 'unpriced', 0, 1, ?, ?) + `), int64(9001), "evt-pk-1", "task-1", nil, nil, now, now) + if err != nil { + t.Fatalf("seed row with explicit id: %v", err) + } + + _, err = repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_usage_events ( + id, usage_event_id, task_id, session_id, turn_id, + agent_profile_id, agent_type, model, provider, + tokens_in, tokens_cached_read, tokens_cached_write, tokens_out, tokens_thought, + tokens_total, cost_subcents, cost_source, estimated, + contract_version, occurred_at, created_at + ) VALUES (?, ?, ?, ?, ?, '', '', '', '', 0, 0, 0, 0, 0, 0, 0, 'unpriced', 0, 1, ?, ?) + `), int64(9001), "evt-pk-2", "task-1", nil, nil, now, now) + if err == nil { + t.Fatal("expected a primary-key conflict inserting a duplicate id, got nil") + } + if isUsageEventUniqueViolation(err) { + t.Errorf("isUsageEventUniqueViolation(%v) = true, want false (this is a primary-key conflict, not a usage_event_id conflict)", err) + } +} + +// TestIsUsageEventUniqueViolation_SQLiteForeignKeyNotMisclassified proves a +// foreign-key rejection (AC-32's other classified failure kind) is never +// mistaken for a unique violation. +func TestIsUsageEventUniqueViolation_SQLiteForeignKeyNotMisclassified(t *testing.T) { + repo := newUsageEventsTestRepo(t) + now := time.Now().UTC() + + _, err := repo.db.Exec(repo.db.Rebind(` + INSERT INTO task_usage_events ( + usage_event_id, task_id, session_id, turn_id, + agent_profile_id, agent_type, model, provider, + tokens_in, tokens_cached_read, tokens_cached_write, tokens_out, tokens_thought, + tokens_total, cost_subcents, cost_source, estimated, + contract_version, occurred_at, created_at + ) VALUES (?, ?, ?, ?, '', '', '', '', 0, 0, 0, 0, 0, 0, 0, 'unpriced', 0, 1, ?, ?) + `), "evt-no-task", "task-does-not-exist", nil, nil, now, now) + if err == nil { + t.Fatal("expected a foreign-key error inserting a row against an unknown task_id, got nil") + } + if isUsageEventUniqueViolation(err) { + t.Errorf("isUsageEventUniqueViolation(%v) = true, want false (this is a foreign-key violation)", err) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_totals.go b/apps/backend/internal/task/repository/sqlite/usage_totals.go new file mode 100644 index 0000000000..ef90d4e869 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_totals.go @@ -0,0 +1,128 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +// costSourceUnpriced mirrors internal/task/usage.CostSourceUnpriced. It is +// redeclared here rather than imported: internal/task/usage already imports +// this package to satisfy its Repository interface, so importing back would +// cycle. +const costSourceUnpriced = "unpriced" + +// usageTotalsAggregateQuery is AC-18/AC-19's read-side aggregation: every sum +// is order-independent (SUM, COUNT, MIN, MAX), so it needs none of +// ListTaskUsageEvents' (occurred_at, id) ordering (AC-16). A nullable token +// column is coalesced to zero before summing (AC-12) so one not-recorded +// sample can never null out the whole total. tokens_total sums the stored +// per-row column verbatim - it is never recomputed from the per-kind sums at +// read time (AC-19). +const usageTotalsAggregateQuery = ` + SELECT + COUNT(*), + COALESCE(SUM(tokens_in), 0), + COALESCE(SUM(COALESCE(tokens_cached_read, 0)), 0), + COALESCE(SUM(COALESCE(tokens_cached_write, 0)), 0), + COALESCE(SUM(COALESCE(tokens_out, 0)), 0), + COALESCE(SUM(COALESCE(tokens_thought, 0)), 0), + COALESCE(SUM(tokens_total), 0), + COALESCE(SUM(cost_subcents), 0), + COALESCE(SUM(CASE WHEN estimated = 1 THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN cost_source = ? THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN tokens_out IS NULL THEN 1 ELSE 0 END), 0), + MIN(occurred_at), + MAX(occurred_at) + FROM task_usage_events + WHERE %s = ? +` + +// GetTaskUsageTotals aggregates every ledger row for taskID, including rows +// whose session_id has been cleared by session deletion (AC-18, AC-19). +// An unknown task, and a known task with no rows, both return a zeroed +// TaskUsageTotals and a nil error (AC-20) - the caller enforces the 404 for +// an unknown task, since the aggregate query alone cannot distinguish the +// two. +func (r *Repository) GetTaskUsageTotals(ctx context.Context, taskID string) (*models.TaskUsageTotals, error) { + return r.scanUsageTotals(ctx, "task_id", taskID) +} + +// GetSessionUsageTotals aggregates only the ledger rows still bound to +// sessionID (AC-18, AC-19). A session with no rows returns a zeroed +// TaskUsageTotals and a nil error (AC-20). +func (r *Repository) GetSessionUsageTotals(ctx context.Context, sessionID string) (*models.TaskUsageTotals, error) { + return r.scanUsageTotals(ctx, "session_id", sessionID) +} + +// scanUsageTotals runs usageTotalsAggregateQuery scoped to one column. +// scopeColumn is always one of the two internal literals passed by +// GetTaskUsageTotals/GetSessionUsageTotals, never caller input, so +// interpolating it into the query text carries no injection risk. +func (r *Repository) scanUsageTotals(ctx context.Context, scopeColumn, scopeValue string) (*models.TaskUsageTotals, error) { + if scopeColumn != "task_id" && scopeColumn != "session_id" { + return nil, fmt.Errorf("scanUsageTotals: unsupported scope column %q", scopeColumn) + } + query := r.ro.Rebind(fmt.Sprintf(usageTotalsAggregateQuery, scopeColumn)) + + var ( + totals models.TaskUsageTotals + rawFirstEventAt interface{} + rawLastEventAt interface{} + unpricedEventCount sql.NullInt64 + outputIncompleteEventCount sql.NullInt64 + ) + err := r.ro.QueryRowxContext(ctx, query, costSourceUnpriced, scopeValue).Scan( + &totals.EventCount, + &totals.TokensIn, + &totals.TokensCachedRead, + &totals.TokensCachedWrite, + &totals.TokensOut, + &totals.TokensThought, + &totals.TokensTotal, + &totals.CostSubcents, + &totals.EstimatedEventCount, + &unpricedEventCount, + &outputIncompleteEventCount, + &rawFirstEventAt, + &rawLastEventAt, + ) + if err != nil { + return nil, err + } + + totals.UnpricedEventCount = unpricedEventCount.Int64 + totals.OutputTokensComplete = outputIncompleteEventCount.Int64 == 0 + + firstEventAt, err := parseUsageTotalsTimestamp(rawFirstEventAt) + if err != nil { + return nil, fmt.Errorf("parse first_event_at: %w", err) + } + totals.FirstEventAt = firstEventAt + lastEventAt, err := parseUsageTotalsTimestamp(rawLastEventAt) + if err != nil { + return nil, fmt.Errorf("parse last_event_at: %w", err) + } + totals.LastEventAt = lastEventAt + + return &totals, nil +} + +// parseUsageTotalsTimestamp converts a MIN/MAX(occurred_at) scan result to a +// *time.Time, or nil for SQL NULL (no contributing rows). Scanned as +// interface{} because SQLite returns an aggregate over a TIMESTAMP column as +// TEXT/[]byte while PostgreSQL returns a native time.Time; reuses +// parseTaskActivityTime (task_status_summary.go), which already handles both. +func parseUsageTotalsTimestamp(raw interface{}) (*time.Time, error) { + if raw == nil { + return nil, nil + } + parsed, err := parseTaskActivityTime(raw) + if err != nil { + return nil, err + } + return &parsed, nil +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_totals_postgres_test.go b/apps/backend/internal/task/repository/sqlite/usage_totals_postgres_test.go new file mode 100644 index 0000000000..782d70e4ce --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_totals_postgres_test.go @@ -0,0 +1,99 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/testutil" +) + +// GetTaskUsageTotals'/GetSessionUsageTotals' MIN/MAX(occurred_at) columns are +// dialect-sensitive: SQLite returns an aggregate over a TIMESTAMP column as +// TEXT, PostgreSQL returns a native time.Time, and parseUsageTotalsTimestamp +// branches on the scanned Go type to handle both. The SQLite-only tests in +// usage_totals_test.go cannot exercise the PostgreSQL branch at all. +// +// Skips unless KANDEV_TEST_POSTGRES_DSN is set. + +// TestPostgresGetTaskUsageTotals_SumsAndTimestampsRoundTrip is the Postgres +// counterpart to TestGetTaskUsageTotals_SumsAcrossSessionsAndClearedSessionRows +// and TestGetTaskUsageTotals_FirstAndLastEventAt_MinAndMaxOccurredAt: proves +// the aggregate SUM/COUNT columns and the MIN/MAX(occurred_at) timestamp scan +// both work against a real PostgreSQL instance, not just SQLite. +func TestPostgresGetTaskUsageTotals_SumsAndTimestampsRoundTrip(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + seedPostgresTaskSession(t, repo, "task-totals-pg", "session-totals-pg") + ctx := context.Background() + + base := time.Date(2026, 8, 23, 4, 0, 0, 0, time.UTC) + earliest := newTestUsageEvent("evt-totals-pg-1", "task-totals-pg", "session-totals-pg") + earliest.OccurredAt = base + earliest.CreatedAt = base + if err := repo.CreateTaskUsageEvent(ctx, earliest); err != nil { + t.Fatalf("CreateTaskUsageEvent(evt-totals-pg-1): %v", err) + } + latest := newTestUsageEvent("evt-totals-pg-2", "task-totals-pg", "session-totals-pg") + latest.OccurredAt = base.Add(10 * time.Minute) + latest.CreatedAt = latest.OccurredAt + if err := repo.CreateTaskUsageEvent(ctx, latest); err != nil { + t.Fatalf("CreateTaskUsageEvent(evt-totals-pg-2): %v", err) + } + + totals, err := repo.GetTaskUsageTotals(ctx, "task-totals-pg") + if err != nil { + t.Fatalf("GetTaskUsageTotals: %v", err) + } + if totals.EventCount != 2 { + t.Fatalf("EventCount = %d, want 2", totals.EventCount) + } + if totals.TokensIn != 200 { + t.Errorf("TokensIn = %d, want 200", totals.TokensIn) + } + if totals.FirstEventAt == nil || !totals.FirstEventAt.Equal(base) { + t.Errorf("FirstEventAt = %v, want %v", totals.FirstEventAt, base) + } + if totals.LastEventAt == nil || !totals.LastEventAt.Equal(latest.OccurredAt) { + t.Errorf("LastEventAt = %v, want %v", totals.LastEventAt, latest.OccurredAt) + } + + sessionTotals, err := repo.GetSessionUsageTotals(ctx, "session-totals-pg") + if err != nil { + t.Fatalf("GetSessionUsageTotals: %v", err) + } + if sessionTotals.EventCount != 2 { + t.Errorf("session EventCount = %d, want 2", sessionTotals.EventCount) + } +} + +// TestPostgresGetTaskUsageTotals_NoRows_ReturnsZeroedTotalsWithNilTimestamps +// is the Postgres counterpart to TestGetTaskUsageTotals_NoRows_ReturnsZeroedTotals: +// a scope with no rows must scan a SQL NULL MIN/MAX into nil timestamps +// against PostgreSQL's native time.Time driver values too, not just SQLite's +// TEXT NULL. +func TestPostgresGetTaskUsageTotals_NoRows_ReturnsZeroedTotalsWithNilTimestamps(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + seedPostgresTaskSession(t, repo, "task-totals-empty-pg", "session-totals-empty-pg") + + totals, err := repo.GetTaskUsageTotals(context.Background(), "task-totals-empty-pg") + if err != nil { + t.Fatalf("GetTaskUsageTotals: %v", err) + } + if totals.EventCount != 0 { + t.Errorf("EventCount = %d, want 0", totals.EventCount) + } + if totals.FirstEventAt != nil || totals.LastEventAt != nil { + t.Errorf("timestamps = (%v, %v), want (nil, nil)", totals.FirstEventAt, totals.LastEventAt) + } + if !totals.OutputTokensComplete { + t.Error("OutputTokensComplete = false, want true for a scope with no rows") + } +} diff --git a/apps/backend/internal/task/repository/sqlite/usage_totals_test.go b/apps/backend/internal/task/repository/sqlite/usage_totals_test.go new file mode 100644 index 0000000000..c7e9296a74 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/usage_totals_test.go @@ -0,0 +1,312 @@ +package sqlite + +// Behavioral coverage for GetTaskUsageTotals and GetSessionUsageTotals +// (docs/specs/task-cost-ledger/spec.md AC-12, AC-18, AC-19, AC-20): the +// read-side aggregation the two HTTP usage routes surface. + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestScanUsageTotals_RejectsUnsupportedScopeColumn(t *testing.T) { + repo := newUsageEventsTestRepo(t) + _, err := repo.scanUsageTotals(context.Background(), "task_id; DROP TABLE tasks;--", "task-1") + if err == nil || !strings.Contains(err.Error(), "unsupported scope column") { + t.Fatalf("scanUsageTotals error = %v, want an unsupported-scope error", err) + } +} + +func mustCreateUsageEvent(t *testing.T, repo *Repository, event *models.TaskUsageEvent) { + t.Helper() + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("CreateTaskUsageEvent(%s): %v", event.UsageEventID, err) + } +} + +func TestGetTaskUsageTotals_NoRows_ReturnsZeroedTotals(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-empty") + + totals, err := repo.GetTaskUsageTotals(context.Background(), "task-empty") + if err != nil { + t.Fatalf("GetTaskUsageTotals: %v", err) + } + if totals.EventCount != 0 { + t.Errorf("EventCount = %d, want 0", totals.EventCount) + } + if totals.TokensIn != 0 || totals.TokensTotal != 0 || totals.CostSubcents != 0 { + t.Errorf("totals = %+v, want every sum zero", totals) + } + if !totals.OutputTokensComplete { + t.Error("OutputTokensComplete = false, want true for a scope with no rows") + } + if totals.FirstEventAt != nil || totals.LastEventAt != nil { + t.Errorf("timestamps = (%v, %v), want (nil, nil)", totals.FirstEventAt, totals.LastEventAt) + } +} + +func TestGetTaskUsageTotals_UnknownTask_ReturnsZeroedTotals(t *testing.T) { + repo := newUsageEventsTestRepo(t) + + totals, err := repo.GetTaskUsageTotals(context.Background(), "task-does-not-exist") + if err != nil { + t.Fatalf("GetTaskUsageTotals: %v", err) + } + if totals.EventCount != 0 { + t.Errorf("EventCount = %d, want 0", totals.EventCount) + } +} + +// TestGetTaskUsageTotals_SumsAcrossSessionsAndClearedSessionRows pins that a +// task-scoped total includes every row for the task regardless of session_id, +// including a row whose session_id is NULL (e.g. cleared by session +// deletion or an AC-33 ownership mismatch). +func TestGetTaskUsageTotals_SumsAcrossSessionsAndClearedSessionRows(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-multi") + createUsageEventsTestSession(t, repo, "session-a", "task-multi") + createUsageEventsTestSession(t, repo, "session-b", "task-multi") + + mustCreateUsageEvent(t, repo, newTestUsageEvent("evt-a", "task-multi", "session-a")) + mustCreateUsageEvent(t, repo, newTestUsageEvent("evt-b", "task-multi", "session-b")) + mustCreateUsageEvent(t, repo, newTestUsageEvent("evt-c", "task-multi", "")) + + totals, err := repo.GetTaskUsageTotals(context.Background(), "task-multi") + if err != nil { + t.Fatalf("GetTaskUsageTotals: %v", err) + } + if totals.EventCount != 3 { + t.Fatalf("EventCount = %d, want 3", totals.EventCount) + } + if totals.TokensIn != 300 { + t.Errorf("TokensIn = %d, want 300 (3 rows of 100 each)", totals.TokensIn) + } + if totals.CostSubcents != 126 { + t.Errorf("CostSubcents = %d, want 126 (3 rows of 42 each)", totals.CostSubcents) + } +} + +// TestGetTaskUsageTotals_TokensTotalIsStoredSumNeverRecomputed pins AC-19: the +// response's tokens_total is the sum of each row's stored tokens_total +// column, not a value recomputed from the per-kind sums at read time. +func TestGetTaskUsageTotals_TokensTotalIsStoredSumNeverRecomputed(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-stored-total") + + event := newTestUsageEvent("evt-stored-total", "task-stored-total", "") + event.TokensIn = 10 + event.TokensCachedRead = nil + event.TokensCachedWrite = nil + event.TokensOut = nil + event.TokensThought = nil + event.TokensTotal = 999999 // deliberately inconsistent with the per-kind sum (10) + mustCreateUsageEvent(t, repo, event) + + totals, err := repo.GetTaskUsageTotals(context.Background(), "task-stored-total") + if err != nil { + t.Fatalf("GetTaskUsageTotals: %v", err) + } + if totals.TokensTotal != 999999 { + t.Errorf("TokensTotal = %d, want the stored value 999999 verbatim, not a recomputed 10", totals.TokensTotal) + } + if totals.TokensIn != 10 { + t.Errorf("TokensIn = %d, want 10", totals.TokensIn) + } +} + +// TestGetTaskUsageTotals_NilTokenColumnsCoalesceToZero pins AC-12: a +// not-recorded (nil) token column contributes zero to the sum rather than +// nulling out the whole aggregate. +func TestGetTaskUsageTotals_NilTokenColumnsCoalesceToZero(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-nil-cols") + + first := newTestUsageEvent("evt-nil-cols-1", "task-nil-cols", "") + first.TokensCachedRead = nil + first.TokensCachedWrite = nil + first.TokensThought = nil + measuredZero := int64(0) + first.TokensOut = &measuredZero + mustCreateUsageEvent(t, repo, first) + + second := newTestUsageEvent("evt-nil-cols-2", "task-nil-cols", "") + mustCreateUsageEvent(t, repo, second) + + totals, err := repo.GetTaskUsageTotals(context.Background(), "task-nil-cols") + if err != nil { + t.Fatalf("GetTaskUsageTotals: %v", err) + } + if totals.TokensCachedRead != 20 { + t.Errorf("TokensCachedRead = %d, want 20 (nil row contributes 0, not NULL)", totals.TokensCachedRead) + } + if totals.TokensOut != 30 { + t.Errorf("TokensOut = %d, want 30 (measured-zero row contributes 0, non-nil row contributes 30)", totals.TokensOut) + } +} + +// TestGetTaskUsageTotals_OutputTokensComplete_FalseWhenAnyRowMissingTokensOut +// pins AC-12/AC-19: a single contributing row with an unrecorded output +// count marks the whole total a lower bound. +func TestGetTaskUsageTotals_OutputTokensComplete_FalseWhenAnyRowMissingTokensOut(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-incomplete-output") + + complete := newTestUsageEvent("evt-complete", "task-incomplete-output", "") + mustCreateUsageEvent(t, repo, complete) + + incomplete := newTestUsageEvent("evt-incomplete", "task-incomplete-output", "") + incomplete.TokensOut = nil + mustCreateUsageEvent(t, repo, incomplete) + + totals, err := repo.GetTaskUsageTotals(context.Background(), "task-incomplete-output") + if err != nil { + t.Fatalf("GetTaskUsageTotals: %v", err) + } + if totals.OutputTokensComplete { + t.Error("OutputTokensComplete = true, want false (one row has an unrecorded output count)") + } +} + +func TestGetTaskUsageTotals_OutputTokensComplete_TrueWhenEveryRowHasTokensOut(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-complete-output") + + mustCreateUsageEvent(t, repo, newTestUsageEvent("evt-1", "task-complete-output", "")) + mustCreateUsageEvent(t, repo, newTestUsageEvent("evt-2", "task-complete-output", "")) + + totals, err := repo.GetTaskUsageTotals(context.Background(), "task-complete-output") + if err != nil { + t.Fatalf("GetTaskUsageTotals: %v", err) + } + if !totals.OutputTokensComplete { + t.Error("OutputTokensComplete = false, want true (every row has a recorded output count)") + } +} + +// TestGetTaskUsageTotals_EstimatedAndUnpricedCounts pins AC-19's confidence +// flags: estimated_event_count and unpriced_event_count let a caller +// distinguish a confident total from a partial one. +func TestGetTaskUsageTotals_EstimatedAndUnpricedCounts(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-flags") + + priced := newTestUsageEvent("evt-priced", "task-flags", "") + priced.CostSource = "models_dev_list" + mustCreateUsageEvent(t, repo, priced) + + unpriced := newTestUsageEvent("evt-unpriced", "task-flags", "") + unpriced.CostSource = "unpriced" + mustCreateUsageEvent(t, repo, unpriced) + + estimated := newTestUsageEvent("evt-estimated", "task-flags", "") + estimated.Estimated = true + mustCreateUsageEvent(t, repo, estimated) + + totals, err := repo.GetTaskUsageTotals(context.Background(), "task-flags") + if err != nil { + t.Fatalf("GetTaskUsageTotals: %v", err) + } + if totals.EventCount != 3 { + t.Fatalf("EventCount = %d, want 3", totals.EventCount) + } + if totals.UnpricedEventCount != 1 { + t.Errorf("UnpricedEventCount = %d, want 1", totals.UnpricedEventCount) + } + if totals.EstimatedEventCount != 1 { + t.Errorf("EstimatedEventCount = %d, want 1", totals.EstimatedEventCount) + } +} + +func TestGetTaskUsageTotals_FirstAndLastEventAt_MinAndMaxOccurredAt(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-timestamps") + + base := time.Date(2026, 8, 23, 4, 0, 0, 0, time.UTC) + earliest := newTestUsageEvent("evt-earliest", "task-timestamps", "") + earliest.OccurredAt = base + earliest.CreatedAt = base + mustCreateUsageEvent(t, repo, earliest) + + latest := newTestUsageEvent("evt-latest", "task-timestamps", "") + latest.OccurredAt = base.Add(10 * time.Minute) + latest.CreatedAt = latest.OccurredAt + mustCreateUsageEvent(t, repo, latest) + + totals, err := repo.GetTaskUsageTotals(context.Background(), "task-timestamps") + if err != nil { + t.Fatalf("GetTaskUsageTotals: %v", err) + } + if totals.FirstEventAt == nil || !totals.FirstEventAt.Equal(base) { + t.Errorf("FirstEventAt = %v, want %v", totals.FirstEventAt, base) + } + if totals.LastEventAt == nil || !totals.LastEventAt.Equal(latest.OccurredAt) { + t.Errorf("LastEventAt = %v, want %v", totals.LastEventAt, latest.OccurredAt) + } +} + +// TestGetSessionUsageTotals_OnlyIncludesRowsForThatSession pins the +// session-scoped read's isolation: one task with two sessions, each +// session-scoped read returns only its own rows and their sum, never the +// other session's or the task's session-less rows. +func TestGetSessionUsageTotals_OnlyIncludesRowsForThatSession(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-two-sessions") + createUsageEventsTestSession(t, repo, "session-x", "task-two-sessions") + createUsageEventsTestSession(t, repo, "session-y", "task-two-sessions") + + mustCreateUsageEvent(t, repo, newTestUsageEvent("evt-x1", "task-two-sessions", "session-x")) + mustCreateUsageEvent(t, repo, newTestUsageEvent("evt-x2", "task-two-sessions", "session-x")) + mustCreateUsageEvent(t, repo, newTestUsageEvent("evt-y1", "task-two-sessions", "session-y")) + mustCreateUsageEvent(t, repo, newTestUsageEvent("evt-task-only", "task-two-sessions", "")) + + totalsX, err := repo.GetSessionUsageTotals(context.Background(), "session-x") + if err != nil { + t.Fatalf("GetSessionUsageTotals(session-x): %v", err) + } + if totalsX.EventCount != 2 { + t.Errorf("session-x EventCount = %d, want 2", totalsX.EventCount) + } + if totalsX.TokensIn != 200 { + t.Errorf("session-x TokensIn = %d, want 200", totalsX.TokensIn) + } + + totalsY, err := repo.GetSessionUsageTotals(context.Background(), "session-y") + if err != nil { + t.Fatalf("GetSessionUsageTotals(session-y): %v", err) + } + if totalsY.EventCount != 1 { + t.Errorf("session-y EventCount = %d, want 1", totalsY.EventCount) + } +} + +// TestGetSessionUsageTotals_ZeroUsageSessionInsideTaskWithUsage_ReturnsZeroed +// pins the explicitly-flagged Round-5 scenario: a session with zero rows, +// inside a task that does have usage, still reports zeroed totals rather +// than picking up the task's other rows. +func TestGetSessionUsageTotals_ZeroUsageSessionInsideTaskWithUsage_ReturnsZeroed(t *testing.T) { + repo := newUsageEventsTestRepo(t) + createUsageEventsTestTask(t, repo, "task-has-usage") + createUsageEventsTestSession(t, repo, "session-busy", "task-has-usage") + createUsageEventsTestSession(t, repo, "session-idle", "task-has-usage") + + mustCreateUsageEvent(t, repo, newTestUsageEvent("evt-busy", "task-has-usage", "session-busy")) + + totals, err := repo.GetSessionUsageTotals(context.Background(), "session-idle") + if err != nil { + t.Fatalf("GetSessionUsageTotals(session-idle): %v", err) + } + if totals.EventCount != 0 { + t.Errorf("EventCount = %d, want 0", totals.EventCount) + } + if totals.TokensIn != 0 || totals.TokensTotal != 0 { + t.Errorf("totals = %+v, want every sum zero", totals) + } + if !totals.OutputTokensComplete { + t.Error("OutputTokensComplete = false, want true for a scope with no rows") + } +} diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 2893b8e299..9ebbe5a0e0 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -289,6 +289,7 @@ type Repos struct { StatusSummaries repository.TaskStatusSummaryRepository TaskActivity repository.TaskActivityRepository SubagentContexts repository.SubagentContextRepository + Usage repository.UsageRepository } // Service provides task business logic @@ -314,6 +315,7 @@ type Service struct { statusSummaries repository.TaskStatusSummaryRepository taskActivity repository.TaskActivityRepository subagentContexts repository.SubagentContextRepository + usage repository.UsageRepository attachmentSvc *AttachmentService statusSummaryPRs TaskStatusSummaryPRReader statusSummaryProjector TaskStatusSummaryEventProjector @@ -457,6 +459,7 @@ func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discover statusSummaries: repos.StatusSummaries, taskActivity: repos.TaskActivity, subagentContexts: repos.SubagentContexts, + usage: repos.Usage, eventBus: eventBus, logger: log, discoveryConfig: discoveryConfig, diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 5de6a5f284..ae3b966675 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -205,6 +205,7 @@ func createTestServiceWithSessionsRepo( TaskEnvironments: repo, Reviews: repo, ResourceCleanups: repo, + Usage: repo, }, eventBus, log, RepositoryDiscoveryConfig{}) svc.SetWorkspaceBootstrapper(repo) svc.SetWorkflowStepGetter(&testWorkflowStepGetter{repo: repo}) diff --git a/apps/backend/internal/task/service/service_usage.go b/apps/backend/internal/task/service/service_usage.go new file mode 100644 index 0000000000..27f3fb6e75 --- /dev/null +++ b/apps/backend/internal/task/service/service_usage.go @@ -0,0 +1,34 @@ +package service + +import ( + "context" + + "github.com/kandev/kandev/internal/task/models" +) + +// GetTaskUsageTotals returns the task-cost-ledger aggregate for taskID +// (docs/specs/task-cost-ledger/spec.md AC-18, AC-19), including rows whose +// session_id was cleared by session deletion. authorizeTaskID alone does not +// prove the task exists - it is a no-op for unscoped (internal/synthetic) +// callers - so an explicit GetTask call supplies the 404 for an unknown task +// in every caller scope. +func (s *Service) GetTaskUsageTotals(ctx context.Context, taskID string) (*models.TaskUsageTotals, error) { + if err := s.authorizeTaskID(ctx, taskID); err != nil { + return nil, err + } + if _, err := s.tasks.GetTask(ctx, taskID); err != nil { + return nil, err + } + return s.usage.GetTaskUsageTotals(ctx, taskID) +} + +// GetTaskSessionUsageTotals returns the task-cost-ledger aggregate scoped to +// sessionID (AC-18, AC-19). AuthorizeTaskSessionAccess always fetches the +// session and checks that it belongs to taskID regardless of caller scope, +// so it alone supplies both the unknown-session and mismatched-pair 404s. +func (s *Service) GetTaskSessionUsageTotals(ctx context.Context, taskID, sessionID string) (*models.TaskUsageTotals, error) { + if err := s.AuthorizeTaskSessionAccess(ctx, taskID, sessionID); err != nil { + return nil, err + } + return s.usage.GetSessionUsageTotals(ctx, sessionID) +} diff --git a/apps/backend/internal/task/service/service_usage_test.go b/apps/backend/internal/task/service/service_usage_test.go new file mode 100644 index 0000000000..2cbab6b681 --- /dev/null +++ b/apps/backend/internal/task/service/service_usage_test.go @@ -0,0 +1,153 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +// Service-layer authorization coverage for the task-cost-ledger read surface +// (docs/specs/task-cost-ledger/spec.md AC-18): GetTaskUsageTotals and +// GetTaskSessionUsageTotals must apply the same per-user workspace scoping as +// every other task/session-keyed entry point (see service_access_test.go). + +func seedUsageEvent(t *testing.T, repo interface { + CreateTaskUsageEvent(context.Context, *models.TaskUsageEvent) error +}, usageEventID, taskID, sessionID string) { + t.Helper() + tokensCachedRead := int64(20) + tokensCachedWrite := int64(5) + tokensOut := int64(30) + event := &models.TaskUsageEvent{ + UsageEventID: usageEventID, + TaskID: taskID, + SessionID: sessionID, + AgentType: "claude", + Model: "claude-sonnet-5", + Provider: "anthropic", + TokensIn: 100, + TokensCachedRead: &tokensCachedRead, + TokensCachedWrite: &tokensCachedWrite, + TokensOut: &tokensOut, + TokensTotal: 155, + CostSubcents: 42, + CostSource: "actual", + ContractVersion: 1, + } + if err := repo.CreateTaskUsageEvent(context.Background(), event); err != nil { + t.Fatalf("seed usage event %s: %v", usageEventID, err) + } +} + +func TestGetTaskUsageTotals_ScopingAndAggregation(t *testing.T) { + svc, _, repo := createTestService(t) + seedScopedWorkspaces(t, repo) + seedUsageEvent(t, repo, "evt-task-usage-1", "task-b", "sess-b") + + if _, err := svc.GetTaskUsageTotals(ctxAs("user-a"), "task-b"); !errors.Is(err, repoerrors.ErrTaskNotFound) { + t.Fatalf("foreign GetTaskUsageTotals: %v", err) + } + + for name, ctx := range map[string]context.Context{ + "owner": ctxAs("user-b"), + "internal": context.Background(), + } { + totals, err := svc.GetTaskUsageTotals(ctx, "task-b") + if err != nil { + t.Fatalf("%s GetTaskUsageTotals: %v", name, err) + } + if totals.EventCount != 1 { + t.Fatalf("%s EventCount = %d, want 1", name, totals.EventCount) + } + if totals.TokensIn != 100 { + t.Fatalf("%s TokensIn = %d, want 100", name, totals.TokensIn) + } + } +} + +func TestGetTaskUsageTotals_UnknownTask(t *testing.T) { + svc, _, repo := createTestService(t) + seedScopedWorkspaces(t, repo) + + for name, ctx := range map[string]context.Context{ + "scoped": ctxAs("user-b"), + "internal": context.Background(), + } { + if _, err := svc.GetTaskUsageTotals(ctx, "task-does-not-exist"); !errors.Is(err, repoerrors.ErrTaskNotFound) { + t.Fatalf("%s unknown task: %v", name, err) + } + } +} + +func TestGetTaskSessionUsageTotals_ScopingAndAggregation(t *testing.T) { + svc, _, repo := createTestService(t) + seedScopedWorkspaces(t, repo) + seedUsageEvent(t, repo, "evt-session-usage-1", "task-b", "sess-b") + + if _, err := svc.GetTaskSessionUsageTotals(ctxAs("user-a"), "task-b", "sess-b"); !errors.Is(err, repoerrors.ErrTaskNotFound) { + t.Fatalf("foreign GetTaskSessionUsageTotals: %v", err) + } + + for name, ctx := range map[string]context.Context{ + "owner": ctxAs("user-b"), + "internal": context.Background(), + } { + totals, err := svc.GetTaskSessionUsageTotals(ctx, "task-b", "sess-b") + if err != nil { + t.Fatalf("%s GetTaskSessionUsageTotals: %v", name, err) + } + if totals.EventCount != 1 { + t.Fatalf("%s EventCount = %d, want 1", name, totals.EventCount) + } + } +} + +func TestGetTaskSessionUsageTotals_MismatchedPairAndUnknownSession(t *testing.T) { + svc, _, repo := createTestService(t) + seedScopedWorkspaces(t, repo) + if err := repo.CreateTask(context.Background(), &models.Task{ + ID: "task-b-other", WorkspaceID: "ws-b", WorkflowID: "wf-b", WorkflowStepID: "step-1", + Title: "B's other task", State: "created", Priority: "medium", + }); err != nil { + t.Fatalf("create other task: %v", err) + } + + for name, ctx := range map[string]context.Context{ + "owner": ctxAs("user-b"), + "internal": context.Background(), + } { + if _, err := svc.GetTaskSessionUsageTotals(ctx, "task-b-other", "sess-b"); !errors.Is(err, repoerrors.ErrTaskNotFound) { + t.Fatalf("%s mismatched pair: %v", name, err) + } + // An outright unknown session ID surfaces models.ErrTaskSessionNotFound + // (not repoerrors.ErrTaskNotFound) - AuthorizeTaskSessionAccess only + // substitutes the task sentinel for a mismatched real session/task pair, + // per its own doc comment. handlers.isNotFound's "not found" substring + // fallback still maps this to HTTP 404. + if _, err := svc.GetTaskSessionUsageTotals(ctx, "task-b", "sess-does-not-exist"); !errors.Is(err, models.ErrTaskSessionNotFound) { + t.Fatalf("%s unknown session: %v", name, err) + } + } +} + +func TestGetTaskUsageTotals_ZeroUsageReturnsZeroedTotals(t *testing.T) { + svc, _, repo := createTestService(t) + seedScopedWorkspaces(t, repo) + + totals, err := svc.GetTaskUsageTotals(ctxAs("user-b"), "task-b") + if err != nil { + t.Fatalf("GetTaskUsageTotals: %v", err) + } + if totals.EventCount != 0 { + t.Fatalf("EventCount = %d, want 0", totals.EventCount) + } + if !totals.OutputTokensComplete { + t.Error("OutputTokensComplete = false, want true for zero usage") + } + if totals.FirstEventAt != nil || totals.LastEventAt != nil { + t.Errorf("timestamps = (%v, %v), want (nil, nil)", totals.FirstEventAt, totals.LastEventAt) + } +} diff --git a/apps/backend/internal/task/usage/build.go b/apps/backend/internal/task/usage/build.go new file mode 100644 index 0000000000..064e997395 --- /dev/null +++ b/apps/backend/internal/task/usage/build.go @@ -0,0 +1,162 @@ +package usage + +import ( + "context" + "math" + "time" + + commoncosts "github.com/kandev/kandev/internal/common/costs" + "github.com/kandev/kandev/internal/task/models" +) + +// Cost-source labels recorded on task_usage_events.cost_source (AC-6, AC-7, +// AC-8). +const ( + CostSourceProviderReported = "provider_reported" + CostSourceModelsDevList = "models_dev_list" + CostSourceUnpriced = "unpriced" +) + +// pricingLookupTimeout is AC-8's writer-owned deadline on a pricing call, +// independent of and much shorter than the lookup implementation's own +// HTTP timeout, so a pricing-catalogue outage degrades to an unpriced row +// instead of stalling the single serial worker. +const pricingLookupTimeout = 2 * time.Second + +// PricingLookup is the narrow interface the writer declares for pricing +// resolution (AC-26): a single call returning both the rates and the +// catalogue version they came from, so a background catalogue refresh +// between two separate calls can never pair one snapshot's rates with +// another snapshot's version. No internal/office/** type crosses this +// boundary - production wiring adapts whatever concrete lookup it uses +// (e.g. modelsdev.Client) to this shape outside this package. +type PricingLookup interface { + LookupForModelWithVersion(ctx context.Context, modelID string) (pricing commoncosts.ModelPricing, version string, ok bool) +} + +// buildRow constructs a fully-populated task_usage_events row from a +// validated payload (AC-2, AC-5, AC-6..AC-9, AC-23, AC-30, AC-31, AC-35). +// occurredAt is supplied by the caller so it is acquired exactly once, on +// the worker, immediately before the first repository attempt. +func (w *Writer) buildRow(ctx context.Context, p *usageEventPayload, occurredAt time.Time) *models.TaskUsageEvent { + usage := p.Usage + if usage == nil { + usage = &promptUsagePayload{} + } + + event := &models.TaskUsageEvent{ + UsageEventID: p.UsageEventID, + TaskID: p.TaskID, + SessionID: p.SessionID, + TurnID: p.TurnID, + AgentProfileID: p.AgentProfileID, + AgentType: p.AgentType, + Model: p.Model, + Provider: resolveProvider(p.AgentType, p.AgentID, p.Model), + Estimated: usage.Estimated, + ContractVersion: ContractVersion, + OccurredAt: occurredAt, + CreatedAt: occurredAt, + } + + event.TokensIn = usage.InputTokens + cachedRead := usage.CachedReadTokens + cachedWrite := usage.CachedWriteTokens + thought := usage.ThoughtTokens + event.TokensCachedRead = &cachedRead + event.TokensCachedWrite = &cachedWrite + event.TokensThought = &thought + if usage.OutputTokensPresent { + out := usage.OutputTokens + event.TokensOut = &out + } + var ok bool + event.TokensTotal, ok = sumTokenCounts( + event.TokensIn, cachedRead, cachedWrite, optionalInt64(event.TokensOut), thought, + ) + if !ok { + return nil + } + + w.resolveCost(ctx, event, usage) + + return event +} + +// resolveProvider derives event.Provider (AC-31): the first non-empty of +// the CLI-id mapping applied to agent_type, the same mapping applied to +// agent_id, a model-prefix match, then "". Office's resolveProvider applies +// the same two extracted helpers in the same order, so the two writers +// label the same event identically. +func resolveProvider(agentType, agentID, model string) string { + if provider := commoncosts.ProviderFromCLI(agentType); provider != "" { + return provider + } + if provider := commoncosts.ProviderFromCLI(agentID); provider != "" { + return provider + } + return commoncosts.ProviderForModel(model) +} + +// resolveCost implements AC-6/AC-7/AC-8/AC-9's three-way cost-resolution +// ladder: a provider-reported cost is recorded verbatim (including an +// explicit zero); otherwise a models.dev catalogue lookup within +// pricingLookupTimeout computes cost_subcents from a single pricing +// snapshot; otherwise the row is still recorded, unpriced. +func (w *Writer) resolveCost(ctx context.Context, event *models.TaskUsageEvent, usage *promptUsagePayload) { + if usage.ProviderReportedCostPresent { + event.CostSubcents = usage.ProviderReportedCostSubcents + event.CostSource = CostSourceProviderReported + return + } + if w.pricing == nil { + event.CostSource = CostSourceUnpriced + return + } + + lookupCtx, cancel := context.WithTimeout(ctx, pricingLookupTimeout) + defer cancel() + pricing, version, ok := w.pricing.LookupForModelWithVersion(lookupCtx, event.Model) + if !ok { + event.CostSource = CostSourceUnpriced + return + } + + cost, ok := commoncosts.CalculateCostSubcentsChecked( + event.TokensIn, optionalInt64(event.TokensCachedRead), optionalInt64(event.TokensCachedWrite), + optionalInt64(event.TokensOut), pricing, + ) + if !ok { + event.CostSource = CostSourceUnpriced + return + } + event.CostSubcents = cost + event.CostSource = CostSourceModelsDevList + event.RateInputPerMillion = int64Ptr(pricing.InputPerMillion) + event.RateCachedReadPerMillion = int64Ptr(pricing.CachedReadPerMillion) + event.RateCachedWritePerMillion = int64Ptr(pricing.CachedWritePerMillion) + event.RateOutputPerMillion = int64Ptr(pricing.OutputPerMillion) + event.PricingCatalogVersion = version +} + +func optionalInt64(v *int64) int64 { + if v == nil { + return 0 + } + return *v +} + +func sumTokenCounts(values ...int64) (int64, bool) { + var total int64 + for _, value := range values { + if value < 0 || total > math.MaxInt64-value { + return 0, false + } + total += value + } + return total, true +} + +func int64Ptr(v int64) *int64 { + return &v +} diff --git a/apps/backend/internal/task/usage/build_test.go b/apps/backend/internal/task/usage/build_test.go new file mode 100644 index 0000000000..61971bae20 --- /dev/null +++ b/apps/backend/internal/task/usage/build_test.go @@ -0,0 +1,292 @@ +package usage + +import ( + "context" + "math" + "testing" + "testing/synctest" + "time" + + commoncosts "github.com/kandev/kandev/internal/common/costs" + "github.com/kandev/kandev/internal/task/models" +) + +var fixedOccurredAt = time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC) + +// TestBuildRow_TokensTotal_SumsAllFiveClasses pins AC-23: tokens_total is +// computed by the writer, never copied from the payload's own total_tokens. +func TestBuildRow_TokensTotal_SumsAllFiveClasses(t *testing.T) { + w := &Writer{} + p := &usageEventPayload{ + UsageEventID: "evt-1", TaskID: "task-1", + Usage: &promptUsagePayload{ + InputTokens: 100, OutputTokens: 30, OutputTokensPresent: true, + CachedReadTokens: 25, CachedWriteTokens: 5, ThoughtTokens: 10, + TotalTokens: 999, // deliberately wrong, to prove it's ignored + }, + } + event := w.buildRow(context.Background(), p, fixedOccurredAt) + if event.TokensTotal != 170 { + t.Errorf("TokensTotal = %d, want 170 (100+30+25+5+10), payload total_tokens must be ignored", event.TokensTotal) + } +} + +func TestBuildRow_TokensTotalOverflow_ReturnsNil(t *testing.T) { + w := &Writer{} + p := &usageEventPayload{ + UsageEventID: "evt-overflow", TaskID: "task-1", + Usage: &promptUsagePayload{InputTokens: math.MaxInt64, CachedReadTokens: 1}, + } + if event := w.buildRow(context.Background(), p, fixedOccurredAt); event != nil { + t.Fatalf("buildRow returned %+v, want nil for an overflowing token total", event) + } +} + +// TestBuildRow_OutputTokensNotPresent_LeavesTokensOutNil pins AC-4/AC-30: +// an unsampled output-token count stores as NULL (nil), not zero, and +// contributes zero to tokens_total either way. +func TestBuildRow_OutputTokensNotPresent_LeavesTokensOutNil(t *testing.T) { + w := &Writer{} + p := &usageEventPayload{ + UsageEventID: "evt-1", TaskID: "task-1", + Usage: &promptUsagePayload{InputTokens: 50, OutputTokensPresent: false}, + } + event := w.buildRow(context.Background(), p, fixedOccurredAt) + if event.TokensOut != nil { + t.Errorf("TokensOut = %v, want nil", event.TokensOut) + } + if event.TokensTotal != 50 { + t.Errorf("TokensTotal = %d, want 50", event.TokensTotal) + } +} + +// TestBuildRow_OccurredAtAndCreatedAt_EqualTheSuppliedInstant pins that +// both timestamps are stamped once, from the caller-supplied occurredAt, +// and are equal on a first write. +func TestBuildRow_OccurredAtAndCreatedAt_EqualTheSuppliedInstant(t *testing.T) { + w := &Writer{} + p := &usageEventPayload{UsageEventID: "evt-1", TaskID: "task-1", Usage: &promptUsagePayload{}} + event := w.buildRow(context.Background(), p, fixedOccurredAt) + if !event.OccurredAt.Equal(fixedOccurredAt) || !event.CreatedAt.Equal(fixedOccurredAt) { + t.Errorf("OccurredAt=%v CreatedAt=%v, want both %v", event.OccurredAt, event.CreatedAt, fixedOccurredAt) + } +} + +// TestBuildRow_ContractVersion_IsOne pins AC-5. +func TestBuildRow_ContractVersion_IsOne(t *testing.T) { + w := &Writer{} + p := &usageEventPayload{UsageEventID: "evt-1", TaskID: "task-1", Usage: &promptUsagePayload{}} + event := w.buildRow(context.Background(), p, fixedOccurredAt) + if event.ContractVersion != 1 { + t.Errorf("ContractVersion = %d, want 1", event.ContractVersion) + } +} + +// TestBuildRow_CarriesModelAgentTypeAgentProfileIDVerbatim pins AC-35. +func TestBuildRow_CarriesModelAgentTypeAgentProfileIDVerbatim(t *testing.T) { + w := &Writer{} + p := &usageEventPayload{ + UsageEventID: "evt-1", TaskID: "task-1", + Model: "claude-sonnet-5", AgentType: "claude-acp", AgentProfileID: "profile-1", + Usage: &promptUsagePayload{}, + } + event := w.buildRow(context.Background(), p, fixedOccurredAt) + if event.Model != "claude-sonnet-5" || event.AgentType != "claude-acp" || event.AgentProfileID != "profile-1" { + t.Errorf("event = %+v, fields did not carry through verbatim", event) + } +} + +// TestBuildRow_MissingModelAgentTypeAgentProfileID_DefaultToEmptyString +// pins AC-35's "default to ” only when the payload's own field is empty" +// rule. +func TestBuildRow_MissingModelAgentTypeAgentProfileID_DefaultToEmptyString(t *testing.T) { + w := &Writer{} + p := &usageEventPayload{UsageEventID: "evt-1", TaskID: "task-1", Usage: &promptUsagePayload{}} + event := w.buildRow(context.Background(), p, fixedOccurredAt) + if event.Model != "" || event.AgentType != "" || event.AgentProfileID != "" { + t.Errorf("event = %+v, want empty-string defaults", event) + } +} + +// TestResolveProvider pins AC-31's fixed fallback order. +func TestResolveProvider(t *testing.T) { + tests := []struct { + name string + agentType, agentID, model string + want string + }{ + {"agent_type CLI mapping wins first", "claude-acp", "", "gpt-4", "anthropic"}, + {"agent_id CLI mapping used when agent_type doesn't map", "unknown-cli", "codex-acp", "claude-sonnet-5", "openai"}, + {"model prefix used when neither CLI id maps", "unknown-cli", "unknown-cli", "gemini-1.5", "google"}, + {"empty string when nothing matches", "unknown-cli", "unknown-cli", "unknown-model", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolveProvider(tt.agentType, tt.agentID, tt.model); got != tt.want { + t.Errorf("resolveProvider(%q,%q,%q) = %q, want %q", tt.agentType, tt.agentID, tt.model, got, tt.want) + } + }) + } +} + +// fakePricingLookup is a test double for PricingLookup. If block is true, +// LookupForModelWithVersion blocks until ctx is done and then reports not +// ok, modeling a pricing backend that never itself respects a deadline +// shorter than the writer's own (AC-8's "never returns" scenario). +type fakePricingLookup struct { + pricing commoncosts.ModelPricing + version string + ok bool + block bool + calls int +} + +func (f *fakePricingLookup) LookupForModelWithVersion(ctx context.Context, _ string) (commoncosts.ModelPricing, string, bool) { + f.calls++ + if f.block { + <-ctx.Done() + return commoncosts.ModelPricing{}, "", false + } + return f.pricing, f.version, f.ok +} + +// TestResolveCost_ProviderReportedCostPresent_RecordsVerbatimSkipsPricing +// pins AC-6, including the explicit-zero case. +func TestResolveCost_ProviderReportedCostPresent_RecordsVerbatimSkipsPricing(t *testing.T) { + pricing := &fakePricingLookup{ok: true, pricing: commoncosts.ModelPricing{InputPerMillion: 300}} + w := &Writer{pricing: pricing} + p := &usageEventPayload{ + UsageEventID: "evt-1", TaskID: "task-1", + Usage: &promptUsagePayload{ProviderReportedCostPresent: true, ProviderReportedCostSubcents: 0}, + } + event := w.buildRow(context.Background(), p, fixedOccurredAt) + if event.CostSource != CostSourceProviderReported { + t.Errorf("CostSource = %q, want %q", event.CostSource, CostSourceProviderReported) + } + if event.CostSubcents != 0 { + t.Errorf("CostSubcents = %d, want 0 (explicit provider-reported zero must be recorded, not treated as absent)", event.CostSubcents) + } + if pricing.calls != 0 { + t.Errorf("pricing lookup was called %d times, want 0 (provider-reported cost must skip pricing entirely)", pricing.calls) + } +} + +// TestResolveCost_PricingResolves_ComputesCostAndPopulatesRates pins AC-7, +// AC-9, AC-26: a single lookup call populates cost_subcents, cost_source, +// all four rate columns, and the pricing catalogue version together. +func TestResolveCost_PricingResolves_ComputesCostAndPopulatesRates(t *testing.T) { + pricing := &fakePricingLookup{ + ok: true, version: "catalogue-v7", + pricing: commoncosts.ModelPricing{InputPerMillion: 3_000_000, CachedReadPerMillion: 300_000, CachedWritePerMillion: 3_750_000, OutputPerMillion: 15_000_000}, + } + w := &Writer{pricing: pricing} + p := &usageEventPayload{ + UsageEventID: "evt-1", TaskID: "task-1", + Usage: &promptUsagePayload{ + InputTokens: 100, OutputTokens: 30, OutputTokensPresent: true, + CachedReadTokens: 25, CachedWriteTokens: 5, Estimated: true, + }, + } + event := w.buildRow(context.Background(), p, fixedOccurredAt) + + if event.CostSource != CostSourceModelsDevList { + t.Errorf("CostSource = %q, want %q", event.CostSource, CostSourceModelsDevList) + } + wantCost := (int64(100)*3_000_000 + 25*300_000 + 5*3_750_000 + 30*15_000_000) / 1_000_000 + if event.CostSubcents != wantCost { + t.Errorf("CostSubcents = %d, want %d", event.CostSubcents, wantCost) + } + if event.RateInputPerMillion == nil || *event.RateInputPerMillion != 3_000_000 { + t.Errorf("RateInputPerMillion = %v, want 3000000", event.RateInputPerMillion) + } + if event.RateCachedReadPerMillion == nil || *event.RateCachedReadPerMillion != 300_000 { + t.Errorf("RateCachedReadPerMillion = %v, want 300000", event.RateCachedReadPerMillion) + } + if event.RateCachedWritePerMillion == nil || *event.RateCachedWritePerMillion != 3_750_000 { + t.Errorf("RateCachedWritePerMillion = %v, want 3750000", event.RateCachedWritePerMillion) + } + if event.RateOutputPerMillion == nil || *event.RateOutputPerMillion != 15_000_000 { + t.Errorf("RateOutputPerMillion = %v, want 15000000", event.RateOutputPerMillion) + } + if event.PricingCatalogVersion != "catalogue-v7" { + t.Errorf("PricingCatalogVersion = %q, want %q", event.PricingCatalogVersion, "catalogue-v7") + } + if !event.Estimated { + t.Error("Estimated = false, want true (AC-9: recorded independently of cost_source, straight from the payload)") + } + if pricing.calls != 1 { + t.Errorf("pricing lookup was called %d times, want exactly 1 (a single call must supply both rates and version)", pricing.calls) + } +} + +// TestResolveCost_PricingNotOK_FallsBackToUnpriced pins AC-8: a pricing miss +// still records the row, with cost_subcents=0 and no rate columns. +func TestResolveCost_PricingNotOK_FallsBackToUnpriced(t *testing.T) { + pricing := &fakePricingLookup{ok: false} + w := &Writer{pricing: pricing} + p := &usageEventPayload{UsageEventID: "evt-1", TaskID: "task-1", Usage: &promptUsagePayload{InputTokens: 10}} + event := w.buildRow(context.Background(), p, fixedOccurredAt) + if event.CostSource != CostSourceUnpriced { + t.Errorf("CostSource = %q, want %q", event.CostSource, CostSourceUnpriced) + } + if event.CostSubcents != 0 { + t.Errorf("CostSubcents = %d, want 0", event.CostSubcents) + } + if event.RateInputPerMillion != nil { + t.Errorf("RateInputPerMillion = %v, want nil", event.RateInputPerMillion) + } +} + +func TestResolveCost_OverflowFallsBackToUnpriced(t *testing.T) { + pricing := &fakePricingLookup{ + ok: true, + pricing: commoncosts.ModelPricing{InputPerMillion: math.MaxInt64}, + } + w := &Writer{pricing: pricing} + p := &usageEventPayload{ + UsageEventID: "evt-cost-overflow", TaskID: "task-1", + Usage: &promptUsagePayload{InputTokens: 2}, + } + event := w.buildRow(context.Background(), p, fixedOccurredAt) + if event == nil { + t.Fatal("buildRow returned nil for a cost overflow; token total still fits") + } + if event.CostSource != CostSourceUnpriced || event.CostSubcents != 0 { + t.Fatalf("overflowing cost = (%q, %d), want (unpriced, 0)", event.CostSource, event.CostSubcents) + } +} + +// TestResolveCost_NilPricingLookup_FallsBackToUnpriced pins AC-8's +// no-lookup-wired degradation (relevant before Task 12 wires production +// pricing). +func TestResolveCost_NilPricingLookup_FallsBackToUnpriced(t *testing.T) { + w := &Writer{pricing: nil} + p := &usageEventPayload{UsageEventID: "evt-1", TaskID: "task-1", Usage: &promptUsagePayload{InputTokens: 10}} + event := w.buildRow(context.Background(), p, fixedOccurredAt) + if event.CostSource != CostSourceUnpriced { + t.Errorf("CostSource = %q, want %q", event.CostSource, CostSourceUnpriced) + } +} + +// TestResolveCost_PricingNeverReturns_HonorsWriterOwnedDeadline pins AC-8's +// 2-second writer-owned deadline: a pricing lookup that only respects +// context cancellation must not stall buildRow past pricingLookupTimeout. +// Runs inside synctest so the deadline's real time.Timer advances on the +// fake clock instead of costing real wall time. +func TestResolveCost_PricingNeverReturns_HonorsWriterOwnedDeadline(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + pricing := &fakePricingLookup{block: true} + w := &Writer{pricing: pricing} + p := &usageEventPayload{UsageEventID: "evt-1", TaskID: "task-1", Usage: &promptUsagePayload{InputTokens: 10}} + + done := make(chan *models.TaskUsageEvent, 1) + go func() { + done <- w.buildRow(context.Background(), p, fixedOccurredAt) + }() + + event := <-done + if event.CostSource != CostSourceUnpriced { + t.Errorf("CostSource = %q, want %q", event.CostSource, CostSourceUnpriced) + } + }) +} diff --git a/apps/backend/internal/task/usage/decode.go b/apps/backend/internal/task/usage/decode.go new file mode 100644 index 0000000000..26bbfbd7bb --- /dev/null +++ b/apps/backend/internal/task/usage/decode.go @@ -0,0 +1,66 @@ +// Package usage subscribes to the session-prompt-usage bus subject and +// records an append-only task_usage_events ledger row per observed event +// (docs/specs/task-cost-ledger/spec.md). It must not import any +// internal/office/** package (AC-25): Office records its own, separate +// cost_events ledger off the same bus subject, and this package is a +// sibling consumer, not a dependent. +package usage + +import ( + "encoding/json" + + "github.com/kandev/kandev/internal/events/bus" +) + +// usageEventPayload mirrors lifecycle.SessionPromptUsageEventPayload's JSON +// shape field-for-field (AC-25). It is declared locally rather than +// importing that type because internal/agent/runtime/lifecycle imports +// internal/task/models, so importing it here would point a dependency edge +// back up the stack the wrong way; the wire contract is the JSON, not a +// shared Go type. +type usageEventPayload struct { + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + AgentID string `json:"agent_id"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + AgentType string `json:"agent_type,omitempty"` + Model string `json:"model,omitempty"` + Usage *promptUsagePayload `json:"usage"` + Timestamp string `json:"timestamp"` + TurnID string `json:"turn_id,omitempty"` + UsageEventID string `json:"usage_event_id,omitempty"` +} + +// promptUsagePayload mirrors streams.PromptUsage's JSON shape (AC-30). +// OutputTokensPresent is always serialized by the producer, which is what +// makes an unsampled output-token count distinguishable from a measured +// zero; CachedReadTokens, CachedWriteTokens and ThoughtTokens are plain +// omitempty ints, so for those three an absent field and a measured zero +// are indistinguishable on the wire by design (AC-30). +type promptUsagePayload struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + OutputTokensPresent bool `json:"output_tokens_present"` + CachedReadTokens int64 `json:"cached_read_tokens,omitempty"` + CachedWriteTokens int64 `json:"cached_write_tokens,omitempty"` + ThoughtTokens int64 `json:"thought_tokens,omitempty"` + TotalTokens int64 `json:"total_tokens,omitempty"` + ProviderReportedCostSubcents int64 `json:"provider_reported_cost_subcents,omitempty"` + ProviderReportedCostPresent bool `json:"provider_reported_cost_present,omitempty"` + Estimated bool `json:"estimated,omitempty"` +} + +// decodePayload extracts a typed usageEventPayload from a bus event via a +// JSON marshal/unmarshal round trip (mirrors office/service.decodeEventData +// for the same wire event, without importing that unexported helper). +func decodePayload(event *bus.Event) (*usageEventPayload, error) { + raw, err := json.Marshal(event.Data) + if err != nil { + return nil, err + } + var payload usageEventPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, err + } + return &payload, nil +} diff --git a/apps/backend/internal/task/usage/decode_test.go b/apps/backend/internal/task/usage/decode_test.go new file mode 100644 index 0000000000..a5f101b8c7 --- /dev/null +++ b/apps/backend/internal/task/usage/decode_test.go @@ -0,0 +1,97 @@ +package usage + +import ( + "testing" + + "github.com/kandev/kandev/internal/events/bus" +) + +// TestDecodePayload_ValidEvent_PopulatesAllFields pins that decodePayload's +// locally-declared struct mirrors lifecycle.SessionPromptUsageEventPayload's +// JSON tags (AC-25) well enough to round-trip every field the writer needs. +func TestDecodePayload_ValidEvent_PopulatesAllFields(t *testing.T) { + event := &bus.Event{ + Data: map[string]any{ + "task_id": "task-1", + "session_id": "session-1", + "agent_id": "claude-acp", + "agent_profile_id": "profile-1", + "agent_type": "claude-acp", + "model": "claude-sonnet-5", + "turn_id": "turn-1", + "usage_event_id": "evt-1", + "timestamp": "2026-08-23T12:00:00Z", + "usage": map[string]any{ + "input_tokens": int64(100), + "output_tokens": int64(30), + "output_tokens_present": true, + "cached_read_tokens": int64(25), + "cached_write_tokens": int64(5), + "thought_tokens": int64(10), + "total_tokens": int64(170), + "provider_reported_cost_present": false, + "estimated": false, + }, + }, + } + + payload, err := decodePayload(event) + if err != nil { + t.Fatalf("decodePayload: %v", err) + } + + if payload.TaskID != "task-1" || payload.SessionID != "session-1" || + payload.AgentID != "claude-acp" || payload.AgentProfileID != "profile-1" || + payload.AgentType != "claude-acp" || payload.Model != "claude-sonnet-5" || + payload.TurnID != "turn-1" || payload.UsageEventID != "evt-1" { + t.Fatalf("payload = %+v, fields did not round-trip", payload) + } + + if payload.Usage == nil { + t.Fatal("payload.Usage = nil, want populated") + } + u := payload.Usage + if u.InputTokens != 100 || u.OutputTokens != 30 || !u.OutputTokensPresent || + u.CachedReadTokens != 25 || u.CachedWriteTokens != 5 || u.ThoughtTokens != 10 { + t.Errorf("usage = %+v, fields did not round-trip", u) + } +} + +// TestDecodePayload_OutputTokensPresentFalse_PinsAbsenceDistinctFromZero +// pins AC-4/AC-30's NULL-vs-zero distinction for output tokens: when the +// producer omits an output-token sample, output_tokens_present decodes to +// false even though output_tokens itself defaults to the JSON zero value. +func TestDecodePayload_OutputTokensPresentFalse_PinsAbsenceDistinctFromZero(t *testing.T) { + event := &bus.Event{ + Data: map[string]any{ + "task_id": "task-1", + "usage_event_id": "evt-2", + "usage": map[string]any{ + "input_tokens": int64(50), + // output_tokens_present omitted entirely - never sampled. + }, + }, + } + + payload, err := decodePayload(event) + if err != nil { + t.Fatalf("decodePayload: %v", err) + } + if payload.Usage.OutputTokensPresent { + t.Error("OutputTokensPresent = true, want false when the field is absent from the wire payload") + } + if payload.Usage.OutputTokens != 0 { + t.Errorf("OutputTokens = %d, want 0 (JSON zero value, not meaningful when Present is false)", payload.Usage.OutputTokens) + } +} + +// TestDecodePayload_UndecodableData_ReturnsError pins the dropped:decode_error +// state-machine transition's precondition: a payload that cannot round-trip +// through JSON at all is a decode error, not a validation error. +func TestDecodePayload_UndecodableData_ReturnsError(t *testing.T) { + event := &bus.Event{Data: func() {}} + + if _, err := decodePayload(event); err == nil { + t.Fatal("decodePayload: got nil error, want an error for unmarshalable Data") + } +} diff --git a/apps/backend/internal/task/usage/goleak_test.go b/apps/backend/internal/task/usage/goleak_test.go new file mode 100644 index 0000000000..78aae9d9d1 --- /dev/null +++ b/apps/backend/internal/task/usage/goleak_test.go @@ -0,0 +1,15 @@ +package usage + +import ( + "testing" + + "go.uber.org/goleak" +) + +// TestMain asserts that no goroutines from this package outlive the test +// process. Writer owns a single worker goroutine guarded by Start/Stop; +// goleak catches regressions where Start forgets to register on the +// WaitGroup, or Stop returns before the worker actually exits. +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/apps/backend/internal/task/usage/metrics.go b/apps/backend/internal/task/usage/metrics.go new file mode 100644 index 0000000000..05ad314feb --- /dev/null +++ b/apps/backend/internal/task/usage/metrics.go @@ -0,0 +1,68 @@ +package usage + +import ( + "expvar" + "strings" + + "go.uber.org/zap" +) + +// expvar maps published at package init, exposed via stdlib's /debug/vars +// handler (dev mode). Mirrors internal/office/service/cost_metrics.go's +// shape for the same reason: this ledger's writer has no other observable +// failure surface between "an event arrived on the bus" and "a row exists", +// so every terminal outcome bumps exactly one of these (AC-27). +var ( + eventsWrittenTotal = expvar.NewMap("task_usage_events_written_total") + eventsDroppedTotal = expvar.NewMap("task_usage_events_dropped_total") +) + +// Reasons a session-prompt-usage event never became a stored +// task_usage_events row (AC-27). Every terminal state-machine transition +// increments exactly one of these, via whichever of the fixed +// decode/admit/validate/ownership/insert stages the event fails first. +const ( + dropReasonDecodeError = "decode_error" + dropReasonUnattributable = "unattributable" + dropReasonInvalid = "invalid" + dropReasonDuplicate = "duplicate" + dropReasonOverflow = "overflow" + dropReasonDrainTimeout = "drain_timeout" + dropReasonShutdown = "shutdown" + dropReasonError = "error" +) + +const ( + metricEventWritten = "task_usage.metric.event_written" + metricEventDropped = "task_usage.metric.event_dropped" +) + +func usageMetricLabel(pairs ...string) string { + if len(pairs)%2 != 0 { + return "" + } + parts := make([]string, 0, len(pairs)/2) + for i := 0; i < len(pairs); i += 2 { + parts = append(parts, pairs[i]+"="+pairs[i+1]) + } + return strings.Join(parts, ";") +} + +// recordWritten bumps task_usage_events_written_total, keyed by the row's +// resolved cost source and provider. +func (w *Writer) recordWritten(source, provider string) { + eventsWrittenTotal.Add(usageMetricLabel("source", source, "provider", provider), 1) + if w.log == nil { + return + } + w.log.Debug(metricEventWritten, zap.String("cost_source", source), zap.String("provider", provider)) +} + +// recordDropped bumps task_usage_events_dropped_total, keyed by reason. +func (w *Writer) recordDropped(reason, taskID string) { + eventsDroppedTotal.Add(usageMetricLabel("reason", reason), 1) + if w.log == nil { + return + } + w.log.Warn(metricEventDropped, zap.String("reason", reason), zap.String("task_id", taskID)) +} diff --git a/apps/backend/internal/task/usage/validate.go b/apps/backend/internal/task/usage/validate.go new file mode 100644 index 0000000000..a4af04c65f --- /dev/null +++ b/apps/backend/internal/task/usage/validate.go @@ -0,0 +1,50 @@ +package usage + +// validateStage implements AC-27's validate stage: four sub-checks run in +// a fixed order, so an event failing more than one is still classified +// deterministically by whichever check it fails first. Returns "" when the +// payload passes every check. +func validateStage(p *usageEventPayload) string { + switch { + case p.UsageEventID == "": + return dropReasonInvalid + case p.TaskID == "": + return dropReasonUnattributable + case hasNegativeValue(p.Usage): + return dropReasonInvalid + case hasTokenTotalOverflow(p.Usage): + return dropReasonOverflow + default: + return "" + } +} + +// hasNegativeValue reports whether any token or cost value on u is +// negative (AC-27, sub-check 3). A nil Usage is a defensive-only case - +// publishPromptUsage never publishes with a nil usage object (AC-24) - and +// contributes no negative value. +func hasNegativeValue(u *promptUsagePayload) bool { + if u == nil { + return false + } + if u.InputTokens < 0 || u.OutputTokens < 0 || u.CachedReadTokens < 0 || + u.CachedWriteTokens < 0 || u.ThoughtTokens < 0 { + return true + } + return u.ProviderReportedCostPresent && u.ProviderReportedCostSubcents < 0 +} + +func hasTokenTotalOverflow(u *promptUsagePayload) bool { + if u == nil { + return false + } + _, ok := sumTokenCounts(u.InputTokens, u.CachedReadTokens, u.CachedWriteTokens, optionalOutputTokens(u), u.ThoughtTokens) + return !ok +} + +func optionalOutputTokens(u *promptUsagePayload) int64 { + if !u.OutputTokensPresent { + return 0 + } + return u.OutputTokens +} diff --git a/apps/backend/internal/task/usage/validate_test.go b/apps/backend/internal/task/usage/validate_test.go new file mode 100644 index 0000000000..a18c855768 --- /dev/null +++ b/apps/backend/internal/task/usage/validate_test.go @@ -0,0 +1,115 @@ +package usage + +import ( + "math" + "testing" +) + +// TestValidateStage_AllChecksPass_ReturnsEmptyReason pins the happy path: +// a fully-formed payload passes validate with no drop reason. +func TestValidateStage_AllChecksPass_ReturnsEmptyReason(t *testing.T) { + p := &usageEventPayload{ + UsageEventID: "evt-1", + TaskID: "task-1", + Usage: &promptUsagePayload{InputTokens: 100}, + } + if reason := validateStage(p); reason != "" { + t.Errorf("validateStage = %q, want empty (valid)", reason) + } +} + +// TestValidateStage_MissingUsageEventID_ReturnsInvalid pins sub-check (1): +// checked first, regardless of what else is wrong with the payload. +func TestValidateStage_MissingUsageEventID_ReturnsInvalid(t *testing.T) { + p := &usageEventPayload{ + UsageEventID: "", + TaskID: "", + Usage: &promptUsagePayload{InputTokens: -1}, + } + if reason := validateStage(p); reason != dropReasonInvalid { + t.Errorf("validateStage = %q, want %q (usage_event_id missing wins over every other failure)", reason, dropReasonInvalid) + } +} + +// TestValidateStage_MissingTaskID_ReturnsUnattributable pins sub-check (2). +func TestValidateStage_MissingTaskID_ReturnsUnattributable(t *testing.T) { + p := &usageEventPayload{ + UsageEventID: "evt-1", + TaskID: "", + Usage: &promptUsagePayload{InputTokens: 10}, + } + if reason := validateStage(p); reason != dropReasonUnattributable { + t.Errorf("validateStage = %q, want %q", reason, dropReasonUnattributable) + } +} + +// TestValidateStage_MissingTaskIDAndNegativeValue_ReturnsUnattributable pins +// the specific (2)-vs-(3) ordering AC-27 calls out by name: a payload +// missing task_id AND carrying a negative token value counts +// unattributable, never reaching the negative-value check. +func TestValidateStage_MissingTaskIDAndNegativeValue_ReturnsUnattributable(t *testing.T) { + p := &usageEventPayload{ + UsageEventID: "evt-1", + TaskID: "", + Usage: &promptUsagePayload{InputTokens: -5}, + } + if reason := validateStage(p); reason != dropReasonUnattributable { + t.Errorf("validateStage = %q, want %q (task_id-missing must win over negative-value)", reason, dropReasonUnattributable) + } +} + +// TestValidateStage_NegativeValue_ReturnsInvalid pins sub-check (3) across +// every field it covers, including the provider-reported cost. +func TestValidateStage_NegativeValue_ReturnsInvalid(t *testing.T) { + tests := map[string]*promptUsagePayload{ + "negative input tokens": {InputTokens: -1}, + "negative output tokens": {OutputTokens: -1}, + "negative cached read tokens": {CachedReadTokens: -1}, + "negative cached write tokens": {CachedWriteTokens: -1}, + "negative thought tokens": {ThoughtTokens: -1}, + "negative provider-reported cost": {ProviderReportedCostPresent: true, ProviderReportedCostSubcents: -1}, + } + for name, usage := range tests { + t.Run(name, func(t *testing.T) { + p := &usageEventPayload{UsageEventID: "evt-1", TaskID: "task-1", Usage: usage} + if reason := validateStage(p); reason != dropReasonInvalid { + t.Errorf("validateStage = %q, want %q", reason, dropReasonInvalid) + } + }) + } +} + +// TestValidateStage_NegativeProviderCostButNotPresent_PassesValidation pins +// that an unset provider-reported cost is never inspected for sign, since +// ProviderReportedCostPresent=false means the field simply wasn't sent. +func TestValidateStage_NegativeProviderCostButNotPresent_PassesValidation(t *testing.T) { + p := &usageEventPayload{ + UsageEventID: "evt-1", + TaskID: "task-1", + Usage: &promptUsagePayload{ProviderReportedCostPresent: false, ProviderReportedCostSubcents: -1}, + } + if reason := validateStage(p); reason != "" { + t.Errorf("validateStage = %q, want empty (unset provider cost is not sign-checked)", reason) + } +} + +// TestValidateStage_NilUsage_PassesValidation pins the defensive-only nil +// guard: publishPromptUsage never publishes a nil Usage (AC-24), but the +// decode stage doesn't reject one either, so validate must not panic. +func TestValidateStage_NilUsage_PassesValidation(t *testing.T) { + p := &usageEventPayload{UsageEventID: "evt-1", TaskID: "task-1", Usage: nil} + if reason := validateStage(p); reason != "" { + t.Errorf("validateStage = %q, want empty", reason) + } +} + +func TestValidateStage_TokenTotalOverflow_ReturnsOverflow(t *testing.T) { + p := &usageEventPayload{ + UsageEventID: "evt-1", + TaskID: "task-1", + Usage: &promptUsagePayload{InputTokens: math.MaxInt64, CachedReadTokens: 1}, + } + if reason := validateStage(p); reason != dropReasonOverflow { + t.Errorf("validateStage = %q, want %q for an overflowing token total", reason, dropReasonOverflow) + } +} diff --git a/apps/backend/internal/task/usage/writer.go b/apps/backend/internal/task/usage/writer.go new file mode 100644 index 0000000000..173f52390a --- /dev/null +++ b/apps/backend/internal/task/usage/writer.go @@ -0,0 +1,198 @@ +package usage + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +// ContractVersion is AC-5's per-row contract version: it advances only when +// a column's meaning changes, never when a column is merely added. +const ContractVersion = 1 + +// eventQueueCapacity is AC-34's fixed bounded-channel size between the bus +// callback and the single worker goroutine. +const eventQueueCapacity = 1024 + +// drainDeadline is AC-34's bound on how long Stop waits for buffered events +// to finish draining before abandoning whatever remains. +const drainDeadline = 5 * time.Second + +// Repository is the narrow persistence capability the writer needs. +// Implemented by *sqlite.Repository in production; AC-33's ownership check +// and AC-32's retry/backoff classification already live inside it (Task 7), +// so the writer only builds a complete row and classifies the error it +// gets back. +type Repository interface { + CreateTaskUsageEvent(ctx context.Context, event *models.TaskUsageEvent) error +} + +// Writer subscribes to the session-prompt-usage bus subject and records one +// task_usage_events row per observed event. The bus callback only decodes +// and hands off (AC-34); a single long-lived worker goroutine does the +// database work serially, off the publisher's own goroutine, so +// occurred_at acquisition stays monotonic with arrival order. +type Writer struct { + repo Repository + pricing PricingLookup + log *logger.Logger + + events chan *usageEventPayload + + mu sync.Mutex + started bool + draining bool + + workCtx context.Context + cancelWork context.CancelFunc + wg sync.WaitGroup +} + +// NewWriter constructs a Writer. pricing may be nil, in which case every +// event resolves cost_source=unpriced (AC-8's no-lookup-wired degradation). +// log may be nil; metrics are still recorded, just not logged. +func NewWriter(repo Repository, pricing PricingLookup, log *logger.Logger) *Writer { + workCtx, cancel := context.WithCancel(context.Background()) + return &Writer{ + repo: repo, + pricing: pricing, + log: log, + events: make(chan *usageEventPayload, eventQueueCapacity), + workCtx: workCtx, + cancelWork: cancel, + } +} + +// Start launches the single worker goroutine. Idempotent. +func (w *Writer) Start() { + w.mu.Lock() + defer w.mu.Unlock() + if w.started { + return + } + w.started = true + w.wg.Add(1) + go w.run() +} + +// Stop stops accepting new events, drains whatever is already buffered, and +// returns once the buffer is empty or drainDeadline has elapsed, whichever +// comes first (AC-34). Anything still buffered at the deadline - including +// the event in flight, whose context is cancelled at that instant - is +// counted dropped:drain_timeout and never written. Idempotent. +func (w *Writer) Stop() { + w.mu.Lock() + if !w.started || w.draining { + w.mu.Unlock() + return + } + w.draining = true + close(w.events) + w.mu.Unlock() + + timer := time.AfterFunc(drainDeadline, w.cancelWork) + defer timer.Stop() + w.wg.Wait() +} + +// Subscribe registers the writer's bus callback on the session-prompt-usage +// subject (AC-24). Call after Start. +func (w *Writer) Subscribe(eb bus.EventBus) error { + _, err := eb.Subscribe(events.BuildSessionPromptUsageWildcardSubject(), w.handleBusEvent) + return err +} + +// run is the single serial worker (AC-34): it drains w.events until the +// channel is closed and empty, or until workCtx is cancelled (only ever by +// Stop's drain-deadline timer), whichever comes first. +func (w *Writer) run() { + defer w.wg.Done() + for { + select { + case payload, ok := <-w.events: + if !ok { + return + } + if w.workCtx.Err() != nil { + w.recordDropped(dropReasonDrainTimeout, payload.TaskID) + continue + } + w.processEvent(w.workCtx, payload) + case <-w.workCtx.Done(): + for payload := range w.events { + w.recordDropped(dropReasonDrainTimeout, payload.TaskID) + } + return + } + } +} + +// handleBusEvent is the bus callback (AC-34): decode only, then hand off to +// the worker over the bounded channel. It always returns nil immediately - +// the bus delivers synchronously on the publisher's own goroutine, and this +// callback must never block an agent turn or fail the publish. +func (w *Writer) handleBusEvent(_ context.Context, event *bus.Event) error { + payload, err := decodePayload(event) + if err != nil { + w.recordDropped(dropReasonDecodeError, "") + return nil + } + w.admit(payload) + return nil +} + +// admit hands payload to the worker over the bounded channel without +// blocking (AC-34): a full channel is abandoned as dropped:overflow; an +// event offered after Stop has begun draining is refused as +// dropped:shutdown. The send and the shutdown check share w.mu with Stop's +// close(w.events), so a send can never race a close. +func (w *Writer) admit(p *usageEventPayload) { + w.mu.Lock() + if w.draining { + w.mu.Unlock() + w.recordDropped(dropReasonShutdown, p.TaskID) + return + } + select { + case w.events <- p: + w.mu.Unlock() + default: + w.mu.Unlock() + w.recordDropped(dropReasonOverflow, p.TaskID) + } +} + +// processEvent implements AC-27's validate/ownership/insert stages for one +// dequeued event. occurred_at is acquired exactly once here, on the single +// serial worker, before the first (and only) repository call - the +// repository's own AC-32 retries never re-acquire it. +func (w *Writer) processEvent(ctx context.Context, p *usageEventPayload) { + if reason := validateStage(p); reason != "" { + w.recordDropped(reason, p.TaskID) + return + } + + occurredAt := time.Now().UTC() + event := w.buildRow(ctx, p, occurredAt) + if event == nil { + w.recordDropped(dropReasonOverflow, p.TaskID) + return + } + + err := w.repo.CreateTaskUsageEvent(ctx, event) + switch { + case err == nil: + w.recordWritten(event.CostSource, event.Provider) + case errors.Is(err, sqliterepo.ErrDuplicateUsageEvent): + w.recordDropped(dropReasonDuplicate, p.TaskID) + default: + w.recordDropped(dropReasonError, p.TaskID) + } +} diff --git a/apps/backend/internal/task/usage/writer_integration_test.go b/apps/backend/internal/task/usage/writer_integration_test.go new file mode 100644 index 0000000000..02b5f6ebf3 --- /dev/null +++ b/apps/backend/internal/task/usage/writer_integration_test.go @@ -0,0 +1,92 @@ +package usage + +// End-to-end coverage through the real bus -> Writer -> *sqlite.Repository +// path (docs/specs/task-cost-ledger/spec.md AC-16, AC-34), as opposed to the +// fakeUsageRepo test double used everywhere else in this package. + +import ( + "context" + "fmt" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/common/logger" + dbutil "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +// TestWriter_EndToEnd_RealRepoPreservesArrivalOrder pins AC-16 (occurred_at +// ascending, id as tiebreak) together with AC-34 (the bus callback hands off +// to a single serial worker, off the publisher's own goroutine). occurred_at +// is acquired by the worker at processing time (buildRow), not carried on +// the payload, so fast back-to-back events can legitimately collide on that +// column; proving arrival order survives end to end through a real +// repository also proves the id tiebreak is doing its job, not just that +// the column exists in isolation. +func TestWriter_EndToEnd_RealRepoPreservesArrivalOrder(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "writer-e2e.db") + dbConn, err := dbutil.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + + repo, err := sqliterepo.NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init schema: %v", err) + } + if err := repo.CreateTask(context.Background(), &models.Task{ + ID: "task-e2e", WorkspaceID: "ws-1", Title: "e2e", Priority: "medium", + }); err != nil { + t.Fatalf("CreateTask: %v", err) + } + if err := repo.CreateTaskSession(context.Background(), &models.TaskSession{ + ID: "session-e2e", TaskID: "task-e2e", State: models.TaskSessionStateWaitingForInput, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + + w := NewWriter(repo, nil, nil) + w.Start() + + eb := bus.NewMemoryEventBus(logger.Default()) + if err := w.Subscribe(eb); err != nil { + t.Fatalf("Subscribe: %v", err) + } + + subject := events.BuildSessionPromptUsageSubject("session-e2e") + const n = 8 + for i := 0; i < n; i++ { + payload := map[string]any{ + "task_id": "task-e2e", + "session_id": "session-e2e", + "usage_event_id": fmt.Sprintf("evt-e2e-%d", i), + "usage": map[string]any{"input_tokens": int64(i)}, + } + if err := eb.Publish(context.Background(), subject, &bus.Event{Data: payload}); err != nil { + t.Fatalf("Publish %d: %v", i, err) + } + } + + w.Stop() + + got, err := repo.ListTaskUsageEvents(context.Background(), "task-e2e", 0) + if err != nil { + t.Fatalf("ListTaskUsageEvents: %v", err) + } + if len(got) != n { + t.Fatalf("row count = %d, want %d", len(got), n) + } + for i, row := range got { + want := fmt.Sprintf("evt-e2e-%d", i) + if row.UsageEventID != want { + t.Errorf("row %d usage_event_id = %q, want %q (arrival order must survive the occurred_at+id ordering)", i, row.UsageEventID, want) + } + } +} diff --git a/apps/backend/internal/task/usage/writer_test.go b/apps/backend/internal/task/usage/writer_test.go new file mode 100644 index 0000000000..f2a72f1d9c --- /dev/null +++ b/apps/backend/internal/task/usage/writer_test.go @@ -0,0 +1,374 @@ +package usage + +import ( + "context" + "errors" + "expvar" + "fmt" + "math" + "sync" + "testing" + "testing/synctest" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +// fakeUsageRepo is a Repository test double. If block is true, +// CreateTaskUsageEvent blocks until ctx is done and returns ctx.Err(), +// modeling a wedged database that only responds to context cancellation +// (R5-F2's Test B). +type fakeUsageRepo struct { + mu sync.Mutex + created []*models.TaskUsageEvent + err error + block bool + calls int +} + +func (f *fakeUsageRepo) CreateTaskUsageEvent(ctx context.Context, event *models.TaskUsageEvent) error { + f.mu.Lock() + f.calls++ + f.mu.Unlock() + if f.block { + <-ctx.Done() + return ctx.Err() + } + if f.err != nil { + return f.err + } + f.mu.Lock() + f.created = append(f.created, event) + f.mu.Unlock() + return nil +} + +func (f *fakeUsageRepo) rowCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.created) +} + +func (f *fakeUsageRepo) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls +} + +// expvarMapValue reads one key's counter out of an expvar.Map, returning 0 +// for an absent key. Metrics vars are package-level and shared across every +// test in this binary, so assertions use before/after deltas rather than +// absolute values. +func expvarMapValue(m *expvar.Map, key string) int64 { + var v int64 + m.Do(func(kv expvar.KeyValue) { + if kv.Key == key { + if iv, ok := kv.Value.(*expvar.Int); ok { + v = iv.Value() + } + } + }) + return v +} + +func validPayload(usageEventID string) *usageEventPayload { + return &usageEventPayload{ + UsageEventID: usageEventID, + TaskID: "task-1", + Usage: &promptUsagePayload{InputTokens: 1}, + } +} + +// TestHandleBusEvent_UndecodableData_RecordsDecodeErrorAndReturnsNil pins +// AC-27's decode stage and AC-34's "callback always returns nil" rule. +func TestHandleBusEvent_UndecodableData_RecordsDecodeErrorAndReturnsNil(t *testing.T) { + w := NewWriter(&fakeUsageRepo{}, nil, nil) + before := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", dropReasonDecodeError)) + + err := w.handleBusEvent(context.Background(), &bus.Event{Data: func() {}}) + if err != nil { + t.Fatalf("handleBusEvent returned %v, want nil (must never fail the publish)", err) + } + + after := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", dropReasonDecodeError)) + if after-before != 1 { + t.Errorf("decode_error count delta = %d, want 1", after-before) + } +} + +// TestAdmit_ChannelFull_DropsOverflowWithoutBlocking pins AC-34: a send +// that would block is abandoned, not waited on, and counted +// dropped:overflow. The worker is never started, so nothing drains the +// channel and it fills deterministically. +func TestAdmit_ChannelFull_DropsOverflowWithoutBlocking(t *testing.T) { + w := NewWriter(&fakeUsageRepo{}, nil, nil) + for i := 0; i < eventQueueCapacity; i++ { + w.admit(validPayload(fmt.Sprintf("evt-%d", i))) + } + + before := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", dropReasonOverflow)) + + done := make(chan struct{}) + go func() { + w.admit(validPayload("evt-overflow")) + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("admit blocked instead of dropping when the channel is full") + } + + after := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", dropReasonOverflow)) + if after-before != 1 { + t.Errorf("overflow count delta = %d, want 1", after-before) + } +} + +// TestAdmit_AfterShutdownBegins_DropsShutdownWithoutBlocking pins AC-34: an +// event offered after Stop has begun draining is refused, not queued, and +// counted dropped:shutdown (distinct from overflow). +func TestAdmit_AfterShutdownBegins_DropsShutdownWithoutBlocking(t *testing.T) { + w := NewWriter(&fakeUsageRepo{}, nil, nil) + w.Start() + w.Stop() + + before := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", dropReasonShutdown)) + w.admit(validPayload("evt-late")) + after := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", dropReasonShutdown)) + if after-before != 1 { + t.Errorf("shutdown count delta = %d, want 1", after-before) + } +} + +// TestProcessEvent_InvalidPayload_NeverReachesRepository pins that the +// validate stage's rejection is terminal - an invalid event never reaches +// the ownership/insert stages the repository owns. +func TestProcessEvent_InvalidPayload_NeverReachesRepository(t *testing.T) { + repo := &fakeUsageRepo{} + w := NewWriter(repo, nil, nil) + w.processEvent(context.Background(), &usageEventPayload{UsageEventID: "", TaskID: "task-1"}) + if repo.callCount() != 0 { + t.Errorf("repo.calls = %d, want 0", repo.callCount()) + } +} + +func TestProcessEvent_TokenTotalOverflow_DropsOverflowWithoutRepositoryCall(t *testing.T) { + repo := &fakeUsageRepo{} + w := NewWriter(repo, nil, nil) + before := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", "overflow")) + w.processEvent(context.Background(), &usageEventPayload{ + UsageEventID: "evt-overflow", TaskID: "task-1", + Usage: &promptUsagePayload{InputTokens: math.MaxInt64, CachedReadTokens: 1}, + }) + after := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", "overflow")) + if after-before != 1 { + t.Errorf("overflow count delta = %d, want 1", after-before) + } + if repo.callCount() != 0 { + t.Errorf("repo.calls = %d, want 0 for an overflowing token total", repo.callCount()) + } +} + +// TestProcessEvent_RepoSucceeds_RecordsWritten pins the nil-error +// classification branch. +func TestProcessEvent_RepoSucceeds_RecordsWritten(t *testing.T) { + repo := &fakeUsageRepo{} + w := NewWriter(repo, nil, nil) + label := usageMetricLabel("source", CostSourceUnpriced, "provider", "") + before := expvarMapValue(eventsWrittenTotal, label) + + w.processEvent(context.Background(), validPayload("evt-1")) + + after := expvarMapValue(eventsWrittenTotal, label) + if after-before != 1 { + t.Errorf("written count delta = %d, want 1", after-before) + } + if repo.rowCount() != 1 { + t.Fatalf("repo rows = %d, want 1", repo.rowCount()) + } +} + +// TestProcessEvent_RepoReturnsErrDuplicateUsageEvent_RecordsDropDuplicate +// pins that AC-33/AC-32's already-implemented redelivery outcome is +// classified as dropped:duplicate, not dropped:error. +func TestProcessEvent_RepoReturnsErrDuplicateUsageEvent_RecordsDropDuplicate(t *testing.T) { + repo := &fakeUsageRepo{err: sqliterepo.ErrDuplicateUsageEvent} + w := NewWriter(repo, nil, nil) + before := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", dropReasonDuplicate)) + + w.processEvent(context.Background(), validPayload("evt-1")) + + after := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", dropReasonDuplicate)) + if after-before != 1 { + t.Errorf("duplicate count delta = %d, want 1", after-before) + } +} + +// TestProcessEvent_RepoReturnsOtherError_RecordsDropError pins that every +// other repository failure - including R5-F1's ownership-lookup hard error +// and transient-retry exhaustion, both already handled inside +// CreateTaskUsageEvent - falls through to dropped:error. +func TestProcessEvent_RepoReturnsOtherError_RecordsDropError(t *testing.T) { + repo := &fakeUsageRepo{err: errors.New("boom")} + w := NewWriter(repo, nil, nil) + before := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", dropReasonError)) + + w.processEvent(context.Background(), validPayload("evt-1")) + + after := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", dropReasonError)) + if after-before != 1 { + t.Errorf("error count delta = %d, want 1", after-before) + } +} + +// TestStop_HealthyRepo_DrainsBufferedEventsAndReturnsPromptly is R5-F2's +// Test A: buffered events drain and are recorded, and Stop returns well +// under the 5-second deadline. +func TestStop_HealthyRepo_DrainsBufferedEventsAndReturnsPromptly(t *testing.T) { + repo := &fakeUsageRepo{} + w := NewWriter(repo, nil, nil) + w.Start() + + const n = 5 + for i := 0; i < n; i++ { + w.admit(validPayload(fmt.Sprintf("evt-%d", i))) + } + + start := time.Now() + w.Stop() + elapsed := time.Since(start) + + if elapsed >= drainDeadline { + t.Errorf("Stop took %v, want well under the %v drain deadline for a healthy repository", elapsed, drainDeadline) + } + if repo.rowCount() != n { + t.Errorf("repo rows = %d, want %d (every buffered event must drain before Stop returns)", repo.rowCount(), n) + } +} + +// TestStop_WedgedRepo_HonorsDrainDeadlineAndDropsEverythingAsError is +// R5-F2's Test B: a repository that only responds to context cancellation +// forces Stop to wait out the full drain deadline, after which the +// in-flight event is counted dropped:error, buffered events are counted +// dropped:drain_timeout, and nothing is written. Once workCtx is cancelled, a buffered event still +// waiting in the select may itself reach the repository with an +// already-cancelled context (returning instantly, still an error) rather +// than being classified without a repository call at all - both outcomes +// are dropped:error and neither writes a row, so repo.calls is not pinned +// to an exact count here. Runs inside synctest so the 5-second deadline +// advances on the fake clock instead of costing real wall time. +func TestStop_WedgedRepo_HonorsDrainDeadlineAndDropsEverythingAsError(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + repo := &fakeUsageRepo{block: true} + w := NewWriter(repo, nil, nil) + w.Start() + + // The first admitted event is dequeued by the worker and blocks + // forever inside the repository call (in flight); the fake repo + // blocks every call, so it can never dequeue a second one until + // workCtx is cancelled - these three stay buffered in the channel. + const buffered = 3 + w.admit(validPayload("evt-inflight")) + for i := 0; i < buffered; i++ { + w.admit(validPayload(fmt.Sprintf("evt-buffered-%d", i))) + } + + beforeError := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", dropReasonError)) + beforeDrain := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", "drain_timeout")) + + start := time.Now() + w.Stop() + elapsed := time.Since(start) + + if elapsed < drainDeadline { + t.Errorf("Stop returned after %v, want at least the %v drain deadline", elapsed, drainDeadline) + } + if repo.rowCount() != 0 { + t.Errorf("repo rows = %d, want 0 (nothing may be written once the drain deadline has expired)", repo.rowCount()) + } + if calls := repo.callCount(); calls < 1 { + t.Errorf("repo.calls = %d, want at least 1 (the in-flight event must reach the repository)", calls) + } + afterError := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", dropReasonError)) + afterDrain := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", "drain_timeout")) + if afterError-beforeError != 1 { + t.Errorf("error-drop count delta = %d, want 1 for the in-flight event", afterError-beforeError) + } + if afterDrain-beforeDrain != buffered { + t.Errorf("drain-timeout count delta = %d, want %d for buffered events", afterDrain-beforeDrain, buffered) + } + }) +} + +// TestSubscribe_PublishedEvent_IsRecordedWithoutBlockingThePublisher pins +// AC-24 (subscribes on the real session-prompt-usage wildcard subject) and +// AC-34 (Publish, which delivers synchronously on the publisher's own +// goroutine, must return promptly regardless of downstream processing). +// The repository blocks every call until its context is cancelled, so this +// is the assertion that actually distinguishes the two designs: a handler +// that wrote synchronously on the callback goroutine would make Publish +// itself hang for as long as the repository blocks, and a repo that +// returns instantly (the prior version of this test) can't tell that apart +// from the real async design. synctest.Wait pins that the worker has +// genuinely reached the blocking repository call - not merely that the +// payload is still sitting unconsumed in the channel - before asserting +// Publish already returned. Runs inside synctest so Stop's 5-second drain +// deadline advances on the fake clock instead of costing real wall time. +func TestSubscribe_PublishedEvent_IsRecordedWithoutBlockingThePublisher(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + repo := &fakeUsageRepo{block: true} + w := NewWriter(repo, nil, nil) + w.Start() + + eb := bus.NewMemoryEventBus(logger.Default()) + if err := w.Subscribe(eb); err != nil { + t.Fatalf("Subscribe: %v", err) + } + + subject := events.BuildSessionPromptUsageSubject("session-1") + payload := map[string]any{ + "task_id": "task-1", + "session_id": "session-1", + "usage_event_id": "evt-published", + "usage": map[string]any{"input_tokens": int64(7)}, + } + + start := time.Now() + if err := eb.Publish(context.Background(), subject, &bus.Event{Data: payload}); err != nil { + t.Fatalf("Publish: %v", err) + } + if elapsed := time.Since(start); elapsed > 100*time.Millisecond { + t.Errorf("Publish took %v, want near-instant (the callback must never block on the worker)", elapsed) + } + + synctest.Wait() + if calls := repo.callCount(); calls != 1 { + t.Fatalf("repo.calls = %d, want 1 (the worker must have reached the repository despite Publish already returning)", calls) + } + if repo.rowCount() != 0 { + t.Fatalf("repo rows = %d, want 0 (the fake never returns success while its call is still blocked)", repo.rowCount()) + } + + before := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", dropReasonError)) + w.Stop() + after := expvarMapValue(eventsDroppedTotal, usageMetricLabel("reason", dropReasonError)) + if after-before != 1 { + t.Errorf("error-drop count delta = %d, want 1 (Stop's drain deadline cancels the in-flight call)", after-before) + } + }) +} + +// TestStartStop_Idempotent pins that calling Start or Stop more than once +// is a safe no-op (matches the internal/integrations/healthpoll shape). +func TestStartStop_Idempotent(t *testing.T) { + w := NewWriter(&fakeUsageRepo{}, nil, nil) + w.Start() + w.Start() + w.Stop() + w.Stop() +} diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 41625a658e..d4ce2a0884 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -136,12 +136,13 @@ Per-workspace credentials and triage triggers for external services. ## costs/ — cost tracking & budgets -Subscription quota tracking and per-agent cheap-model profile routing. +Subscription quota tracking, per-agent cheap-model profile routing, and the production per-turn usage ledger. | Spec | Status | |---|---| | [subscription-usage](costs/subscription-usage.md) | draft | | [cheap-model-profiles](costs/cheap-model-profiles.md) | shipped | +| [task cost & token ledger](task-cost-ledger/spec.md) | draft | ## ui/ — cross-cutting UI features diff --git a/docs/specs/office/costs.md b/docs/specs/office/costs.md index e60f07d922..14d7ab6dbc 100644 --- a/docs/specs/office/costs.md +++ b/docs/specs/office/costs.md @@ -87,7 +87,7 @@ Multiple policies can apply to the same scope (e.g. monthly + total). ### `TaskSession` additions -`cost_subcents`, `tokens_in`, `tokens_out`, and `tokens_cached_in` are incrementally updated as cost events arrive. They provide quick per-session totals without a scan of `office_cost_events`. An unknown ledger `tokens_out` value contributes zero to the numeric rollup. Therefore, the session output total is a lower bound when at least one ledger row has `tokens_out=NULL`. Consumers that need completeness must read the raw ledger. `tokens_cached_in` is DB-only today (no model/DTO/API/web reader). It exists so the rollup reconciles column-for-column against the ledger. The rollup consumes only the merged `tokens_cached_in` sum, not the `tokens_cached_read` / `tokens_cached_write` split. The split exists solely on the ledger row for cost-per-token-type analysis (contract v2). +`cost_subcents`, `tokens_in`, `tokens_out`, and `tokens_cached_in` are maintained by the task cost ledger writer, not this Office subscriber ([task-cost-ledger](../task-cost-ledger/spec.md) AC-10/AC-21). They provide quick per-session totals without a scan of `task_usage_events`. An unknown ledger `tokens_out` value contributes zero to the numeric rollup, so the session output total is a lower bound when at least one ledger row has `tokens_out=NULL`. Consumers needing completeness must read the raw ledger. `tokens_cached_in` is in TaskSession and DTO, without a reader, so the rollup reconciles column-for-column against that ledger. The rollup consumes only the merged `tokens_cached_in` sum, not the `tokens_cached_read`/`tokens_cached_write` split, which exists solely on the ledger row for cost-per-token-type analysis (contract v2). ### Per-agent budget @@ -200,7 +200,7 @@ There is no per-field permission model. Conformance tests should assert that cos - **models.dev miss**: row recorded with `cost_subcents=0` and `cost_source=unpriced`; `estimated` is unaffected (tracks token-synthesis independently, see the `office_cost_events` table). UI shows "pricing unavailable". - **Prompt-usage event redelivered** (e.g. a reconnecting WS client replaying a buffered stream event): the second `CreateCostEvent` call collides on the unique `usage_event_id` partial index and is dropped as a no-op rather than double-inserting or double-counting the session rollup. - **Writer silently stops**: every path a prompt-usage event can take (decode failure, missing task/session ids, task-fields lookup failure, insert failure or duplicate, or a successful write) increments an `expvar` counter (`cost_events_written_total` / `cost_events_dropped_total`, dev-mode `/debug/vars`) and logs, so a stopped writer is observable instead of silently producing zero rows. -- **Ledger insert and rollup increment commit or roll back together**: `CreateCostEvent` (the `office_cost_events` insert) and `IncrementTaskSessionUsage` (the `task_sessions` rollup) are wrapped in one `*sqlx.Tx` by `recordCostEventAndRollup` whenever the wired session-usage writer supports it (every production wiring does). If the rollup increment fails after the ledger insert, the whole transaction rolls back - nothing is committed - so a redelivery of the same event with the same `usage_event_id` retries the operation cleanly instead of being dropped as a duplicate by the unique index above while the rollup stays short. Falls back to the pre-existing non-atomic two-step write only when the wired writer doesn't implement the transactional capability (for example a simplified test double). +- **`recordCostEventAndRollup` only inserts `office_cost_events`** ([task-cost-ledger](../task-cost-ledger/spec.md) AC-10/AC-21): it no longer increments the `task_sessions` rollup or shares a transaction with it. The ledger writer's own insert (`task_usage_events`) and its `task_sessions` rollup increment are wrapped in one transaction on its side, independent of this call, so a redelivery is idempotent on each ledger's own unique index without the two ledgers coordinating a shared transaction. - **models.dev fetch fails or is canceled**: background refresh falls back to existing on-disk and in-memory data; no crash. The in-flight guard is released and a later refresh can retry. @@ -219,7 +219,7 @@ There is no per-field permission model. Conformance tests should assert that cos - `office_cost_events` rows (full history, never trimmed). Disk-runner rows keyed by `(session_id, provider_event_id)` survive re-ingestion without duplicating; wire-side rows are additionally deduplicated by the unique `usage_event_id` partial index (contract v2). The wire-side `estimated=true` rows for codex are deleted once the matching aggregate row lands. - **Column semantics never change value mid-series across a contract bump.** Legacy rows (written before a column's semantics changed) read NULL, never `0`, for a newly nullable value - the downstream point-in-time extract has no schema versioning, so a column whose meaning flips from "not recorded" to "recorded as zero" (or vice versa) partway through the series would be silently discontinuous. This applies both to columns introduced by contract v2 and to `tokens_out`, an existing column whose contract became nullable in v3: pre-v3 rows read `0`, not NULL, for `tokens_out`. Contract v3 changes the meaning of `estimated` for new rows only; legacy rows keep their recorded value. `cost_contract_version` on each row is the in-band marker a consumer checks instead of inferring from a date. - `office_budget_policies` rows. -- `TaskSession.cost_subcents` / `tokens_in` / `tokens_out` / `tokens_cached_in` running totals. The ledger insert and rollup increment are atomic (see `Failure modes` above), so they cannot diverge on a partial write. The numeric `tokens_out` rollup is a lower bound when a source row has an unknown output count. +- `TaskSession.cost_subcents` / `tokens_in` / `tokens_out` / `tokens_cached_in` running totals, maintained by the task cost ledger writer, not this Office subscriber ([task-cost-ledger](../task-cost-ledger/spec.md) AC-10/AC-21); its ledger insert and rollup increment are atomic, so they cannot diverge on a partial write. The numeric `tokens_out` rollup is a lower bound when a source row has an unknown output count. - Per-agent `budget_monthly_cents` stored on the agent instance row. - Per-model pricing overrides stored in workspace settings. - The on-disk `models.dev` cache at `/cache/models-dev.json`. Recovery on next boot: the in-memory pricing map is empty until first query; queries fall back to the on-disk file when the background refresh has not yet completed. @@ -257,7 +257,7 @@ No TTL or retention is applied to `office_cost_events`; rows accumulate for the - **GIVEN** a prompt-usage event for an in-progress turn, **WHEN** the cost event is recorded at turn completion, **THEN** `turn_id` is populated with the turn that was resolved at completion time and `usage_event_id` is set to a deterministic id derived from the underlying completion's identity. -- **GIVEN** the same underlying prompt-usage completion is delivered twice (e.g. a reconnecting WS client replaying a buffered stream event), **WHEN** the second copy is processed, **THEN** it derives the same `usage_event_id`, the insert is rejected by the unique partial index, and the session rollup is not incremented a second time. +- **GIVEN** the same underlying prompt-usage completion is delivered twice (e.g. a reconnecting WS client replaying a buffered stream event), **WHEN** the second copy is processed, **THEN** it derives the same `usage_event_id` and the `office_cost_events` insert is rejected by the unique partial index; the `task_sessions` rollup is unaffected, per the ledger writer's idempotent insert ([task-cost-ledger](../task-cost-ledger/spec.md) AC-10/AC-21). - **GIVEN** an `office_cost_events` table that predates contract v2, **WHEN** the repository boots and runs its migration, **THEN** the new columns are added nullable, every pre-existing row reads NULL (never `0`) for all of them, and running the migration again on the same database is a no-op. diff --git a/docs/specs/spec-lint.json b/docs/specs/spec-lint.json index aa56164dd1..26f522bfd4 100644 --- a/docs/specs/spec-lint.json +++ b/docs/specs/spec-lint.json @@ -24,7 +24,7 @@ "docs/specs/lsp-file-intelligence/spec.md": 38089, "docs/specs/office/agents.md": 61654, "docs/specs/office/automations-settings.md": 33607, - "docs/specs/office/costs.md": 33263, + "docs/specs/office/costs.md": 33239, "docs/specs/office/live-updates.md": 38568, "docs/specs/office/overview.md": 34375, "docs/specs/office/routing.md": 40782, @@ -36,6 +36,7 @@ "docs/specs/plugins/spec.md": 77806, "docs/specs/subagent-context-persistence/spec.md": 160552, "docs/specs/system-page/storage-maintenance.md": 54320, + "docs/specs/task-cost-ledger/spec.md": 91931, "docs/specs/ui/ci-pr-automation.md": 57643 } } diff --git a/docs/specs/task-cost-ledger/spec.md b/docs/specs/task-cost-ledger/spec.md new file mode 100644 index 0000000000..d85bd5018e --- /dev/null +++ b/docs/specs/task-cost-ledger/spec.md @@ -0,0 +1,1344 @@ +--- +status: draft +created: 2026-08-23 +owner: nova28 +--- + +# Task Cost & Token Ledger + +Umbrella: [costs](../costs/) · Related: [office/costs](../office/costs.md) - +this spec amends four named passages in that document and Build lands the +amendment in the same change; see *Out of scope*. Everything else there is +unchanged. + +## Why + +An operator running Kandev today cannot answer "what did this task cost?". +Token counts for each turn are captured but buried in a JSON metadata blob with +no cost attached and no way to aggregate; dollar cost is computed only by the +Office subscriber, and Office ships disabled in production +(`KANDEV_FEATURES_OFFICE: prod: "false"`). Every Kanban-only install — the +shipped default — accumulates real spend with no durable, queryable record of +it. + +## What + +This feature adds a production-owned, append-only usage ledger: one immutable +row per observed agent usage event, carrying token counts, resolved dollar +cost, and the inputs needed to re-derive that cost. It runs regardless of the +Office feature flag, and it is the single writer of the per-session usage +rollup. It also adds the first read surface over that ledger: per-task and +per-session usage totals. + +### Recording + +- **AC-1** (Ubiquitous) The system SHALL persist one ledger row for every + agent usage event it observes, in every installation, independent of the + `features.office` toggle. This feature introduces **no new runtime feature + toggle**: it is additive, it cannot alter agent behaviour (see *Failure + modes*), and gating it would reproduce the exact defect it exists to fix — + a shipped default that records nothing. +- **AC-2** (Event-driven) WHEN a usage event is observed for a known task, the + system SHALL record the event's input tokens, cached-read tokens, + cached-write tokens, output tokens, thought tokens, total tokens, resolved + cost in subcents, cost source, and estimation flag as a single new row. +- **AC-3** (Ubiquitous) Ledger rows SHALL be immutable once written. The system + SHALL provide no operation that updates or partially rewrites a recorded row. + Correction is expressed by recording a further row, never by mutating one. +- **AC-4** (Event-driven) WHEN a usage event carries an output-token count that + the adapter did not observe, the system SHALL record output tokens as *not + recorded* (NULL) rather than as zero, so an unobserved sample is + distinguishable from a measured zero. +- **AC-30** (Ubiquitous) Of the four nullable token columns, only `tokens_out` + is reachable as *not recorded* from the current producer, and the writer + SHALL be specified accordingly rather than inventing a rule. + `streams.PromptUsage` (`internal/agentctl/types/streams/agent.go`) carries a + presence bit for output tokens alone - `OutputTokensPresent bool`, always + serialized, which is what makes AC-4 implementable - while + `CachedReadTokens`, `CachedWriteTokens` and `ThoughtTokens` are plain `int64` + with `omitempty`, so an absent field and a measured zero are byte-identical + on the wire. For those three the writer SHALL record the decoded numeric + value, which is `0` when the field is absent, and SHALL NOT map absence to + NULL: that would write *not recorded* over a genuine measured zero, the same + conflation AC-4 forbids in the other direction, and it would do so for the + overwhelming majority of rows. The three columns nonetheless stay nullable, + so a transport that later carries presence bits can record the distinction + without a migration; AC-12's explicit coalesce therefore remains required, + both for `tokens_out` today and for those columns under any such transport. + Consequently a NULL in `tokens_cached_read`, `tokens_cached_write` or + `tokens_thought` is unreachable through the event path today. Scenarios + asserting NULL handling for those three are forward-compatibility cover over + a directly inserted row and SHALL say so; the reachable NULL path is + exercised through `tokens_out`. +- **AC-5** (Ubiquitous) Each row SHALL carry the contract version under which it + was written, so a later contract change can be detected per row rather than + inferred from a date. +- **AC-23** (Ubiquitous) `tokens_total` SHALL be computed by the writer as + `tokens_in + tokens_cached_read + tokens_cached_write + tokens_out + + tokens_thought`, each *not recorded* value contributing zero. It SHALL NOT be + copied from the payload. A provider-reported total that disagreed with the + components would make a single API response self-contradictory; the + components are authoritative and a differing payload total is ignored, not + recorded. An overflowing sum is dropped as `overflow`. +- **AC-24** (Ubiquitous) The ledger writer SHALL consume the prompt-usage event + that already exists on the internal bus: subject + `events.BuildSessionPromptUsageWildcardSubject()` + (`session_prompt_usage.updated.*`, declared in `internal/events/types.go`), + published by the orchestrator's `publishPromptUsage` + (`internal/orchestrator/event_handlers_streaming.go`). This feature + introduces no new event, subject, or publisher, and does not change what the + orchestrator emits. That publisher is **not** unconditional, and this spec + SHALL NOT be read as claiming it is: it returns early when the session id is + empty, when the event bus is nil, or when `payload.Data.Usage` is nil. A turn + whose agent reported no usage object therefore produces no event, and so no + ledger row and no drop counter - it is invisible to this feature rather than + dropped by it. The ledger records exactly what is published and no more. + Widening the publisher's precondition would change the orchestrator's + published contract and is named in *Out of scope*. +- **AC-25** (Ubiquitous) The subscriber and writer SHALL live in a new package + `internal/task/usage`, in the task tier that owns the table, and SHALL NOT + import any `internal/office/**` package. It SHALL decode the bus payload into + a struct it declares itself, whose JSON tags mirror + `lifecycle.SessionPromptUsageEventPayload` - exactly as + `office/service.PromptUsageData` already does for the same event. The wire + contract is the JSON, not a shared Go type. This is what keeps the dependency + direction the backend requires: `internal/agent/runtime/lifecycle` imports + `internal/task/models`, so importing the lifecycle payload type from the task + tier would point an edge back up the stack. +- **AC-34** (Ubiquitous) The subscriber SHALL NOT do its work on the bus + callback goroutine. `MemoryEventBus.Publish` delivers to every regular + subscription **synchronously**, on the publisher's own goroutine - its call + site says so ("deliver to all synchronously to preserve ordering") - and the + publisher here is `publishPromptUsage`, on the orchestrator's streaming path. + A blocking handler would therefore put a database write, and under AC-32 up + to three attempts with backoff between them, directly in front of agent + streaming, contradicting the guarantee in *Failure modes* that recording + never affects an agent run. The bus callback SHALL decode the payload, hand + the result to a single long-lived worker over a bounded buffered channel, and + return `nil` immediately. Specifically: + - The channel SHALL hold **1024** events. A send that would block SHALL be + abandoned rather than waited on, counted `dropped:overflow` (AC-27), and + logged at warn with the task id. Blocking on a full channel reintroduces + exactly the coupling this criterion removes, and silently discarding + without a counter reproduces the invisible-writer failure AC-27 exists to + bound. + - Exactly **one** worker SHALL drain the channel, serially. That is what + makes `occurred_at` assignment monotonic and therefore what makes AC-16's + `(occurred_at, id)` order match arrival order, and it keeps AC-32's backoff + off every other goroutine in the process. A goroutine per event - the shape + `office/service`'s `maybeAsync` uses for this same subject - SHALL NOT be + used: it is unbounded under burst and it makes `occurred_at` acquisition + racy against arrival, so two events could be ordered opposite to the order + they were published in. + - On shutdown the writer SHALL stop accepting new events, drain what is + already buffered, and return once the buffer is empty or after **5 + seconds**, whichever comes first. Events still buffered at that deadline + are counted `dropped:drain_timeout` and logged. An unbounded drain would hang + process shutdown behind a wedged database; a drain of zero would discard + every buffered event on every restart. + - An event offered by the bus callback **after** the drain has begun SHALL be + refused rather than queued, counted `dropped:shutdown` (AC-27), and logged; + the callback SHALL still return `nil` immediately, because the bus delivers + on the publisher's goroutine and a closing writer may no more block an + agent turn than a busy one. `shutdown` marks a closed writer, not queue + backpressure. + - The event **in flight** at the deadline - the one the worker is writing, as + distinct from those still queued behind it - SHALL be resolved too. The + worker's per-event context SHALL derive from a context cancelled at the + drain deadline, and AC-32's backoff sleeps SHALL honour that cancellation + rather than run to completion. A cancelled in-flight event is counted + `dropped:error`, its transaction rolls back, and **no ledger row or rollup + increment is written after the drain returns**. Left unspecified, a writer + may commit into a pool the process is closing, which is a use-after-close + rather than a late row; and "shutdown returns within 5 seconds" becomes + untestable, because the assertion cannot distinguish a drain that returned + from a worker still running behind it. + - The writer SHALL be constructed, started, and subscribed to the bus in + `internal/backendapp/main.go` **outside** the `if !cfg.Features.Office` + early return that today encloses `RegisterEventSubscribers` - the + Office-disabled install is the one this feature exists for - and its drain + SHALL be registered through the same `addCleanup` mechanism as the other + shutdown steps, at a point **after** the repository cleanups are + registered. `runCleanups` runs registrations in **LIFO** order, so + registering later means draining earlier, which is what puts the drain + ahead of the database pool's close. Registered before them it would run + after the pool closed and every drained event would fail on a closed pool - + the exact failure the bounded drain exists to prevent, reached by getting + the ordering backwards. + + Decoding on the callback and writing on the worker is deliberate: a decode + failure is attributable to the event that caused it (`dropped:decode_error`) + and costs no queue slot, while the expensive and retry-prone part is what + gets detached. +- **AC-35** (Event-driven) WHEN a usage event is recorded, the writer SHALL + carry `model`, `agent_type` and `agent_profile_id` onto the row from the + correspondingly named payload fields, writing `''` for one only when the + payload's own field is empty. AC-2 enumerates the token and cost values and + stops there; without this criterion none of these three is required by + anything, all three are `NOT NULL DEFAULT ''`, and a writer that leaves all + three empty satisfies every other criterion in this spec. It would also make + AC-7's audit columns useless: the row would record the rates and the + catalogue version that priced it, but not what was priced, so the cost could + be neither re-derived nor attributed to a model or a profile. `agent_type` is + additionally an input AC-31 derives `provider` from, so leaving it empty + silently degrades that derivation to its model-prefix tier. +- **AC-27** (Ubiquitous) The writer SHALL export two expvar maps, visible at + `/debug/vars`, mirroring the shape of + `internal/office/service/cost_metrics.go`: + `task_usage_events_written_total` keyed + `source=;provider=`, and + `task_usage_events_dropped_total` keyed `reason=` where reason is one + of `decode_error`, `unattributable`, `invalid`, `duplicate`, `overflow`, + `drain_timeout`, `shutdown`, `error`. Every + terminal transition in *State machine* SHALL increment exactly one counter, so + a writer that has silently stopped is observable and the drop classes are + distinguishable from each other. The stages SHALL run in the order **decode, + admit, validate, ownership, insert** - each stage's failure terminal, so + exactly one counter is incremented however many stages an event could have + failed. Admit is AC-34's queue hand-off, placed after decode so a malformed + payload costs no queue slot: an event refused because the queue is full + counts `overflow`, and one refused because the shutdown drain has already + begun counts `shutdown` (AC-34). Uniqueness is deliberately **not** a stage + of its own - duplicates are detected by the unique constraint at insert, + which is the last stage (AC-13, AC-32) - so an event that is both a + redelivery and invalid counts `invalid`, because it never reaches the insert. + Validation likewise precedes AC-33's session-ownership check, so an event + that is both invalid and session/task-mismatched counts `invalid` and is not + recorded at all. The reason is not preference: validation reads only the + decoded payload, while the ownership check costs a database read, and an + event already known unrecordable SHALL NOT cause one. + + Within the validate stage the sub-checks SHALL themselves run in a fixed + order, so an event failing more than one is classified deterministically: + (1) `usage_event_id` present and non-empty, else `invalid`; (2) `task_id` + present and non-empty, else `unattributable`; (3) no token or cost value + negative, else `invalid`. That order follows the schema's own requiredness - + the idempotency key, then the mandatory attribution column, then value sanity + - and only the (2)-versus-(3) pairing is observable, since (1) and (3) share + a label: an event missing `task_id` **and** carrying a negative token count + counts `unattributable`, not `invalid`. Left unstated, two conforming + implementations disagree on that event's counter and both satisfy this + criterion. + + These counters observe the **subscriber**, not the producer. They + bound the failure "the writer received events and stopped recording them"; + they deliberately do not bound "the producer stopped publishing", because per + AC-24 an unpublished turn increments neither counter. No surface SHALL + present them as proof that a task's recorded spend is complete. + +### Cost resolution + +Cost resolution reuses the two-layer contract already proven in +[office/costs](../office/costs.md) and records which layer produced the number. + +- **AC-6** (Event-driven) WHEN the usage event carries a provider-reported cost + and its presence flag is set, the system SHALL record that amount verbatim + (including an explicit zero) with `cost_source = provider_reported`, and + SHALL NOT perform a pricing lookup. +- **AC-7** (Event-driven) WHEN no provider-reported cost is present and pricing + is resolvable for the event's model, the system SHALL compute cost from the + token counts and record it with `cost_source = models_dev_list`, together + with the per-million rates and pricing-catalog version used. The computation + is exactly: + + ``` + cost_subcents = (tokens_in * rate_input_per_million + + tokens_cached_read * rate_cached_read_per_million + + tokens_cached_write * rate_cached_write_per_million + + tokens_out * rate_output_per_million) / 1000000 + ``` + + All four products are summed in 64-bit integer arithmetic **before** the + single division, and that division truncates toward zero. Overflow records + an `unpriced` row with zero cost. Rounding happens + once, on the summed numerator, never per token class: rounding four times + drifts from rounding once, and two writers that disagree about where to round + produce different money for identical input. A *not recorded* (NULL) token + count contributes zero to the numerator. `tokens_thought` is not priced. + This reproduces `costs.CalculateCostSubcents` + (`internal/office/costs/pricing.go`) exactly, so a row priced here and a row + priced by Office from the same tokens and rates are identical to the + subcent. +- **AC-8** (Unwanted behaviour) IF no provider-reported cost is present and + pricing cannot be resolved, THEN the system SHALL record the row with + `cost_subcents = 0` and `cost_source = unpriced`, and SHALL NOT drop the row. + Token counts are still recorded. A lookup that has not returned within a + writer-imposed deadline of **2 seconds** counts as "cannot be resolved": the + writer SHALL bound every pricing call with its own context deadline and treat + a breach exactly as a miss. The deadline is the writer's, not the lookup's, + because the lookup's own default is far larger - + `internal/office/costs/modelsdev/client.go` constructs its HTTP client with + `Timeout: 30 * time.Second`, six times AC-34's entire 5-second drain - and + because the injected interface (AC-26) admits any implementation. Unbounded, + the degradation this criterion promises does not happen during the outage it + exists for: the single serial worker (AC-34) stalls per event, the 1024-slot + queue fills behind it, and `dropped:overflow` becomes the steady state + exactly where `unpriced` rows were specified. 2 seconds is chosen to sit + comfortably inside the 5-second drain, so an event being priced when shutdown + begins can still finish and commit rather than being abandoned. +- **AC-9** (Ubiquitous) `estimated` SHALL reflect the usage sample's authority + as reported by the adapter and SHALL be recorded independently of + `cost_source`. An estimated row counts toward totals at face value. +- **AC-26** (Ubiquitous) The pricing arithmetic of AC-7 SHALL be extracted to + `internal/common/costs` - the rate struct and the cost function - with + `internal/office/costs` delegating to it, so both writers compute identically + and neither imports the other's tier. The models.dev catalogue lookup SHALL be + constructed in `internal/backendapp/main.go` outside the `features.office` + branch that holds it today (the `SetPricingLookup` / `SetModelInfoLookup` + wiring) and injected into the ledger writer through a narrow interface the + writer declares itself, so no office type crosses the boundary. + **Only the construction moves.** Both existing setter calls stay inside the + `if cfg.Features.Office` block: `services.Office.SetPricingLookup` must, + because `services.Office` is nil in a gated install, and + `orchestratorSvc.SetModelInfoLookup` SHALL as well. That second call feeds a + wholly unrelated capability - the orchestrator's context-window fallback, + per its own doc comment in `internal/orchestrator/model_info.go` - which is + gated today and which this card does not touch. It shares only the + `pricingLookup` variable and the enclosing `if`, so a literal reading of + "construct it outside the gate" would carry it out too and silently enable + context-window fallback for every Office-disabled install, which is the + shipped default this feature exists for. The block therefore splits: an + always-run construction whose value is injected into the ledger writer, and + a still-gated pair of setter calls. Changing when `SetModelInfoLookup` runs + is named in *Out of scope*. That + interface SHALL return the rates and the catalogue version from a **single + call**, so both come from one snapshot - mirroring + `shared.PricingLookupWithVersion` (`internal/office/shared/interfaces.go`), + which exists for exactly this reason and which `lookupPricingWithVersion` + type-asserts. Two independent calls SHALL NOT be used: a catalogue that + refreshes between them writes a new `pricing_catalog_version` beside the + previous `rate_*_per_million` values, and the resulting row is + indistinguishable from a correct one while being unreproducible, which + defeats the only purpose those columns have. IF that wiring + is absent, THEN AC-8 applies and rows record as `unpriced`. That degradation + bounds build order; it is not permission to leave the lookup unwired. +- **AC-31** (Ubiquitous) The `provider` value SHALL be derived by the writer, + because the bus payload does not carry one: + `lifecycle.SessionPromptUsageEventPayload` has `agent_type`, `agent_id` and + `model`, and no provider field. Derivation SHALL be the first non-empty of, + in order: the CLI-id mapping applied to `agent_type`; the same mapping + applied to `agent_id`; a model-prefix match; then `''`. The CLI mapping is + `claude-acp` to `anthropic`, `codex-acp` and `openai-acp` to `openai`, + `gemini` and `gemini-acp` to `google`. The two CLI tiers are not redundant + with the prefix match and SHALL NOT be collapsed into it: claude-acp reports + logical aliases (`sonnet`, `haiku`) that no prefix match resolves, which is + the documented reason `providerFromCLI` exists. Both helpers SHALL be + extracted to `internal/common/costs` alongside the rate struct above - + `ProviderForModel` from `internal/office/costs/pricing.go` and + `providerFromCLI` from `internal/office/service/event_subscribers.go` - with + Office's `resolveProvider` delegating to the extracted versions, so the two + writers label the same event identically. Copying the mapping into + `internal/task/usage` SHALL NOT be used to satisfy AC-25's no-office-import + rule: `internal/common/` is where this backend puts cross-tier shared code, + and a second copy is how the two PTY implementations drifted. An unresolved + provider is the empty string, which is a valid value for a + `NOT NULL DEFAULT ''` column and a valid expvar label. + +### Rollup + +- **AC-10** (Ubiquitous) The system SHALL maintain cumulative + `tokens_in`, `tokens_cached_in`, `tokens_out`, and `cost_subcents` on the + owning `task_sessions` row, and this ledger writer SHALL be the only writer + of those columns. +- **AC-21** (Event-driven) WHEN `features.office` is enabled, the Office cost + subscriber SHALL NOT increment the `task_sessions` rollup columns. The + increment is removed from `recordCostEventAndRollup` + (`internal/office/service/event_subscribers.go`), which continues to insert + its own `office_cost_events` row exactly as today; the ledger writer performs + the increment for both. This is the concrete edit AC-10's sole-writer rule + requires. Without it an Office-enabled install double-counts every turn, and + Office is enabled in the `dev` and `e2e` profiles, so the defect ships to + every developer before it reaches an operator. The tests that pin the removed + increment, and the shared-transaction plumbing it leaves behind, are resolved + by AC-36. +- **AC-29** (Ubiquitous) `Repository.BackfillSessionTokensCachedIn` + (`internal/task/repository/sqlite/base_migrations.go`) and its + composition-root call SHALL be removed. It is a third writer of the AC-10 + columns, and left in place it destroys ledger data on every restart. It is an + **assignment**, not an increment: it sets `task_sessions.tokens_cached_in` to + `COALESCE((SELECT SUM(tokens_cached_in) FROM office_cost_events WHERE + session_id = task_sessions.id), 0)` for every row matching + `EXISTS (SELECT 1 FROM office_cost_events ...) OR tokens_cached_in <> 0`. Its + own doc comment records that it runs unconditionally on every boot with no + run-once tracking, and `internal/backendapp/storage.go` calls it on every + startup with **no `features.office` gate** - it sits immediately after + `office.Provide`, which is itself ungated. In the Office-disabled install this + feature exists for, `office_cost_events` is empty, so the `OR tokens_cached_in + <> 0` arm matches every session this ledger has just written and assigns zero; + a failure there also aborts boot. Removal SHALL also delete the + `sessionCachedTokenBackfiller` interface, the `backfillSessionCachedTokens` + helper, and the construction-order comment that ties repository provisioning + to it. Rows already reconciled by it keep their current values and are not + recomputed, consistent with the no-backfill rule in *Out of scope*. Gating it + behind `features.office` SHALL NOT be used instead: after AC-21 the Office + subscriber no longer maintains those columns in any install, so + `office_cost_events` is no longer their source of truth anywhere and + re-deriving from it is wrong everywhere, not merely misplaced. Seven existing + tests pin the surface this criterion deletes; their disposition is AC-36, and + Build SHALL NOT decide it ad hoc. +- **AC-36** (Ubiquitous) Every production surface removed or changed by AC-21 + and AC-29 SHALL have the tests that pin it resolved in the same change, and + this criterion states which, because deleting or skipping a test to make a + build green is otherwise forbidden and Build has no authority to grant itself + the exception. Two of the tests below assert as correct the exact behaviour + those criteria identify as destructive, so they cannot be left passing + either. The rule is: a test whose subject no longer exists is deleted with + it, which is not a weakening; a test whose subject moved to a new owner is + rewritten against that owner, never deleted; and where a deleted test carried + an assertion nothing else reproduces, that assertion SHALL be re-established + against the ledger writer **before** the deletion lands, so no round of this + change is net-negative on coverage. + + | Surface removed or changed | Tests pinning it | Disposition | + |---|---|---| + | `Repository.BackfillSessionTokensCachedIn` (AC-29) | `task/repository/sqlite/session_usage_test.go`: `TestBackfillSessionTokensCachedIn_SumsLedger`, `_CorrectsDriftedRollup`, `_ZeroesRollupWithNoMatchingLedgerRows`, `_SkipsAlreadyCorrectUntouchedSessions` | **Deleted with the function.** Their subject is gone. `_ZeroesRollupWithNoMatchingLedgerRows` in particular asserts the zeroing AC-29 identifies as data loss, so it cannot be retained or adapted. AC-12 is the surviving statement of the rollup-equals-ledger invariant these defended, and is separately tested. | + | same, Postgres parity (AC-29) | `task/repository/sqlite/session_usage_postgres_test.go`: `TestPostgresBackfillSessionTokensCachedIn_SumsLedger`, `_HandlesValuesBeyondInt32Range` | **Deleted with the function**, with one precondition: `_HandlesValuesBeyondInt32Range` is the only beyond-`int32` rollup coverage in the suite, so AC-28's equivalent boundary assertion against the ledger writer's rollup SHALL land first. | + | `backfillSessionCachedTokens` call site, helper, and `sessionCachedTokenBackfiller` interface in `backendapp/storage.go` (AC-29) | `backendapp/storage_cached_tokens_test.go`: `TestProvideRepositoriesBackfillsSessionCachedTokens` | **Deleted.** It asserts the boot path calls the backfiller, which is precisely the behaviour being removed. Its replacement observable already exists: AC-29's two-boot restart scenario, which passes only because nothing recomputes the rollup. | + | the rationale comment in `backendapp/storage_telemetry_test.go` citing that test as its precedent | (comment only) | **Rewritten** to cite a surviving neighbour. A comment pointing at a deleted test is a dangling reference inside the suite, and this one explains why an unrelated boot-order assertion exists. | + | rollup increment inside `recordCostEventAndRollup` (AC-21) | `office/service/session_usage_reconcile_test.go`: `TestPromptUsage_RollupReconcilesWithCostLedger` | **Deleted.** It asserts Office's rollup equals the sum of `office_cost_events`, which AC-21 makes false by design in every install. | + | same (AC-21) | `office/service/event_subscribers_cost_atomicity_test.go`: `TestPromptUsage_RollupFailureRollsBackLedgerInsertThenRetrySucceeds` | **Rewritten against the ledger writer.** The behaviour it encodes - a rollup failure rolls the insert back, a retry then succeeds - is still required, by AC-11 and AC-32; only its owner changes. Deleting it would drop the one existing test of that interaction. | + | the shared-transaction plumbing (`IncrementTaskSessionUsageTx` and the repository-sharing helpers) | `office/repository/sqlite/cost_event_tx_atomicity_test.go`: `TestCostEventTxAtomicity_RealReposShareTransaction` | **Retained, repointed.** The abstraction is explicitly NOT removed: AC-11 requires exactly this capability for the ledger writer, so AC-21 removes a *caller*, not the mechanism. The test SHALL exercise the ledger writer's repositories instead of Office's. | + +- **AC-32** (Event-driven) WHEN a ledger insert fails, the writer SHALL classify + the failure before reacting, and SHALL NOT distinguish failure kinds by + matching the driver's message text uniformly across dialects: SQLite reports + `FOREIGN KEY constraint failed` without naming the constraint, while Postgres + names it, so one string match is wrong on one dialect. Classification SHALL + use dialect-aware helpers added to `internal/db` beside the existing + `IsDuplicateColumnError` / `IsAlreadyExistsError` and following their shape + (a `pgconn.PgError` SQLSTATE check with a SQLite-specific fallback): + `IsForeignKeyViolation` (Postgres `23503`) and `IsTransientError` (Postgres + `40001` serialization failure and `40P01` deadlock; SQLite `SQLITE_BUSY` and + `SQLITE_LOCKED`). + + A unique violation is classified by a **third** detector, which deliberately + does NOT live in `internal/db`: it SHALL be constraint-specific, declared in + the task repository package (`internal/task/repository/sqlite`) beside the + table it guards, matching Postgres on `pgErr.Code == "23505" && + pgErr.ConstraintName == "uniq_task_usage_events_usage_event_id"` and SQLite + on the constraint-bearing message + `UNIQUE constraint failed: task_usage_events.usage_event_id`, and SHALL + surface to its caller as a package-level sentinel error matched with + `errors.Is`. This is not a new idea in this repo: it is the shape of + `isExternalIDUniqueViolation` / `uniq_tasks_external_id` + (`internal/task/repository/sqlite/task_external_id.go`) and of + `isUsageEventUniqueViolation` / `ErrDuplicateUsageEvent` / + `uniq_office_cost_usage_event` + (`internal/office/repository/sqlite/costs.go`), and it is already a stated + contract in a prior spec of this feature's own tier - + `docs/specs/tasks/external-id-idempotency/spec.md`, "Unique-violation + classification across dialects" - which gives the reason: "A bare `23505` + check is insufficient: it matches any unique violation, so a primary-key + failure would be misread as a Found outcome." Here the misread is worse than + a wrong label: `duplicate` is the one drop class this spec declares *not* an + error, so a violation of some other constraint classified as a duplicate is + swallowed silently and counted as expected behaviour. + + Detection SHALL be by the constraint **at insert**, NOT by a pre-insert + `SELECT`. A read-then-insert pair is not atomic: two concurrent deliveries of + the same deterministic identifier can both read before either writes, both + find nothing, and both attempt the insert - so the constraint decides the + outcome regardless, after the read has already misled the writer about which + branch it is on. The reaction SHALL be: + - **transient** - re-run the whole unit of work, unchanged, in a **fresh + transaction** per attempt, to a maximum of three attempts in total. "The + insert" here means the AC-11 transaction entire - the ledger insert *and* + the rollup increment - not the INSERT statement alone. Postgres aborts the + whole transaction on both `40001` and `40P01`, so re-issuing one statement + inside the failed transaction returns `25P02 current transaction is + aborted` instead of reproducing the original condition, which would make + the three-attempt guarantee unreachable on that dialect while appearing to + work on SQLite. The row keeps its `session_id` and its rollup increment. + Backoff between attempts SHALL be **50 ms then 100 ms** - fixed + exponential, base 50 ms, factor 2, no jitter, no cap needed at two waits - + bounding a retried event at roughly 150 ms of added latency. That latency + lands on the writer's own worker goroutine (AC-34) and never on the + agent's, which is what makes a blocking sleep acceptable here at all. + Jitter is deliberately omitted: with a single serial writer (AC-34) there + is no fleet of peers to synchronize with, so it would add variance without + reducing contention. Exhausted attempts are `dropped:error`. + - **foreign key** - retry exactly once with `session_id = NULL` and no rollup + increment. This deliberately requires no session-versus-task + discrimination: if the violated key was `task_id`, the retry is rejected + too and the row is `dropped:error`, which is already the specified outcome + for that case. + - **unique violation on `usage_event_id`** - no retry, no rollup increment, + counted `dropped:duplicate` (AC-14), never `dropped:error`. This is the + only insert failure that is an expected outcome rather than a fault, and it + is what makes AC-14 reachable at all: without this bucket the "anything + else" branch below claims every redelivery, so a correctly deduplicating + ledger would report a climbing error count and a permanently zero duplicate + count, and *Failure modes*' "not an error" row would be unimplementable. + - **anything else** - `dropped:error`, no retry. + + A transient failure SHALL NOT take the foreign-key path. Nulling the session + of a row that would have committed on a second attempt silently discards + attribution the writer actually had, and does so precisely under load, where + it is least likely to be noticed. +- **AC-33** (Event-driven) WHEN a usage event carries both a task id and a + session id, the writer SHALL verify that the referenced session belongs to + that task before inserting. IF they disagree, THEN the row SHALL be recorded + under the payload's `task_id` with `session_id = NULL` and no rollup + increment, and a warning SHALL be logged. It is not dropped: the spend is + real, the task attribution is what the caller asserted, and the session + reference is the field proven inconsistent. This is deliberately the same end + state as an absent session row, so the two share one code path. Without this + rule the writer can create rows the read side is specified to refuse - a + session-scoped read returns 404 when the session does not belong to the task + in the path - and `IncrementTaskSessionUsageTx` updates by session id alone, + so it would have incremented another task's session rollup. +- **AC-11** (Event-driven) WHEN a ledger row is recorded, the ledger insert and + the rollup increment SHALL be applied in a single transaction, so no + committed state exists in which one landed and the other did not. +- **AC-12** (Ubiquitous) For any session whose usage was recorded by this + ledger, the rollup columns SHALL equal the sum of that session's ledger rows, where `tokens_cached_in` sums + `cached_read + cached_write`. Every nullable token column contributes zero + when it is *not recorded* (NULL): this applies to `tokens_cached_read`, + `tokens_cached_write`, `tokens_out`, and `tokens_thought` alike, in the rollup + increment and in every read aggregation. The zero SHALL be produced by an + explicit coalesce, not left to SQL, where `NULL + n` is `NULL` and one + unrecorded sample would null out a whole session's total. Consequently the + session output total is a lower bound when any of its rows has an unrecorded + output count. The equality is a forward guarantee. Sessions whose rollup was + written by Office before AC-21 removed that increment have no ledger rows + behind those values, are not backfilled (see *Out of scope*), and are outside + this criterion. +- **AC-28** (Ubiquitous) `task_sessions.cost_subcents`, `task_sessions.tokens_in` + and `task_sessions.tokens_out` SHALL be widened to `BIGINT`, so all four + rollup columns satisfy the same 64-bit rule as the ledger. The migration is + idempotent and dialect-aware in the established `runMigrations()` shape: on + Postgres it alters the column type; on SQLite it is a no-op, because SQLite's + `INTEGER` is already 64-bit. + +### Idempotency and ordering + +- **AC-13** (Ubiquitous) The externally minted usage event identifier SHALL be + unique across the ledger and SHALL be the idempotency key. +- **AC-14** (Unwanted behaviour) IF an event bearing a **deterministic** + identifier is redelivered with an identifier already present in the ledger, + THEN the system SHALL record no new row, apply no rollup increment, and + report the drop as a duplicate rather than as an error. +- **AC-22** (Ubiquitous) Deduplication SHALL be exactly as strong as the + identifier the producer minted, and SHALL NOT be described as stronger. + `usageEventIDFor` (`internal/orchestrator/event_handlers_streaming.go`) + derives a deterministic UUIDv5 from + `(session_id, execution_id, prompt_generation)` only when all three are + present; when any is missing it returns a fresh random UUID, which + [office/costs](../office/costs.md) already documents deliberately: + "Generation-less transports receive a random per-publish key, so those rows + are intentionally not deduplicated." For that transport class the system + SHALL record a redelivered event as a NEW row and SHALL apply its rollup + increment. The ledger records what it was told. AC-14 is therefore a + guarantee about identifiers, not a guarantee that spend is never counted + twice, and no read surface may present it as the latter. Changing the minting + rule is out of scope (see *Out of scope*). A test that exercises AC-14 with a + hand-chosen fixed identifier proves nothing about this path; the redelivery + scenarios below cover both classes deliberately. +- **AC-15** (Ubiquitous) A single turn MAY produce multiple ledger rows. + `(session_id, turn_id)` is an aggregation key, NOT a uniqueness constraint. +- **AC-16** (Ubiquitous) Ledger reads SHALL be ordered by `occurred_at` + ascending with the monotonic `id` column as the tiebreak. This pair is a + total order: no two rows compare equal, so repeated reads return an identical + sequence. `id` orders rows by the instant the writer acquired it, which under + concurrent commits may differ from commit order; the contract is determinism + and stability, not a claim that `id` reproduces commit sequence. + + This ordering binds a named read. The repository SHALL expose + `ListTaskUsageEvents(ctx, taskID string, limit int) ([]TaskUsageEvent, error)` + returning that task's rows in `(occurred_at, id)` ascending order, `limit` of + zero or less meaning no limit. An unknown task and a task with no rows both + return an empty slice and a nil error, never an error and never a nil slice. + It is repository-internal: it backs the append-only and ordering assertions + in *Scenarios*, and *Out of scope* keeps it off the HTTP surface. Without a + named method this criterion would constrain a read no builder is required to + write, and the two specified routes return aggregates whose `MIN`/`MAX` and + `SUM` are order-independent - they cannot observe it. +- **AC-17** (Unwanted behaviour) IF a usage event arrives with no turn + identifier, THEN the system SHALL still record the row with a null `turn_id` + and SHALL still apply the rollup. A missing turn identifier degrades + per-turn grouping only; it never costs a row. In the sampled database this is + the majority case (1,135 of 1,962 rows), so dropping such events would + discard most recorded spend. + +### Reading + +- **AC-18** (Ubiquitous) The system SHALL expose per-task usage totals and + per-session usage totals over HTTP, in every installation, independent of the + `features.office` toggle. +- **AC-19** (Ubiquitous) Totals SHALL report tokens by kind, total cost in + subcents, the number of contributing events, and whether any contributing row + was estimated or unpriced, so a caller can tell a confident total from a + partial one. `tokens_total` in a response SHALL be the sum of the stored + `tokens_total` column over the contributing rows, never recomputed from the + per-kind columns at read time. The response SHALL report every kind AC-23 + sums - `tokens_in`, `tokens_cached_read`, `tokens_cached_write`, `tokens_out` + and `tokens_thought` - so that "the two agree by construction" is a property + the caller can actually verify. Because AC-23 computes the stored column on + write from exactly those kinds and the response neither recomputes nor omits + one, a response cannot contradict itself. Omitting a summed kind would make + `tokens_total` exceed the visible kinds for every reasoning-heavy model while + the spec claimed the opposite. +- **AC-20** (Ubiquitous) A task or session with no recorded usage SHALL return + zeroed totals with a zero event count, not an error and not an empty body. + `output_tokens_complete` SHALL be `true` in that case: no contributing row has + an unrecorded output count, so the zero total is exact rather than a lower + bound. `estimated_event_count` and `unpriced_event_count` are `0` and both + timestamps are null. + +## Data model + +### `task_usage_events` (new) + +Owned by the task repository (`internal/task/repository/sqlite`), alongside +`task_step_transitions`, whose append-only-log conventions it follows: +dialect-aware monotonic integer primary key, a `contract_version` column, and +`(occurred_at, id)` ordering. + +``` +task_usage_events + id integer PK, monotonic + (SQLite AUTOINCREMENT / Postgres BIGSERIAL) + usage_event_id text NOT NULL — idempotency key (AC-13) + task_id text NOT NULL, FK -> tasks(id) ON DELETE CASCADE + session_id text nullable, FK -> task_sessions(id) + ON DELETE SET NULL + turn_id text nullable, no FK + agent_profile_id text NOT NULL DEFAULT '' + agent_type text NOT NULL DEFAULT '' -- CLI engine slug + model text NOT NULL DEFAULT '' + provider text NOT NULL DEFAULT '' + tokens_in bigint NOT NULL DEFAULT 0 + tokens_cached_read bigint nullable — NULL = not recorded + tokens_cached_write bigint nullable — NULL = not recorded + tokens_out bigint nullable — NULL = not observed (AC-4) + tokens_thought bigint nullable + tokens_total bigint NOT NULL DEFAULT 0 - computed, AC-23 + cost_subcents bigint NOT NULL DEFAULT 0 + cost_source text NOT NULL — provider_reported + | models_dev_list | unpriced + estimated integer NOT NULL DEFAULT 0 + rate_input_per_million bigint nullable + rate_cached_read_per_million bigint nullable + rate_cached_write_per_million bigint nullable + rate_output_per_million bigint nullable + pricing_catalog_version text nullable + contract_version integer NOT NULL + occurred_at timestamp NOT NULL + created_at timestamp NOT NULL + + UNIQUE INDEX uniq_task_usage_events_usage_event_id (usage_event_id) + INDEX (task_id, occurred_at, id) + INDEX (session_id, turn_id) + INDEX (occurred_at) +``` + +The uniqueness of `usage_event_id` SHALL be that **named** unique index, not +an inline column constraint. AC-32's Postgres detector matches on +`pgErr.ConstraintName`, so the name is part of the contract: an auto-generated +name would differ between a fresh install and a migrated one and make the +detector unportable between them. It is created the way +`uniq_office_cost_usage_event` is +(`internal/office/repository/sqlite/base_migrations.go`) and named the way +`uniq_tasks_external_id` is. + +Units and nullability rules: + +- `cost_subcents` is hundredths of a cent (int64). A caller renders dollars by + dividing by 10,000. This matches + `office_cost_events.cost_subcents` and `task_sessions.cost_subcents`; no new + unit is introduced. There is no `cost_usd` column — a float dollar amount + would make sums non-associative and is deliberately not stored. +- A nullable token column means *not recorded*; zero means *measured zero*. + The two are never conflated (AC-4). Which columns can actually carry *not + recorded* is bounded by what the wire can express: only `tokens_out` today + (AC-30). In every arithmetic context - the rollup + increment, the read aggregations, and the AC-7 pricing numerator - a *not + recorded* value contributes zero and MUST be coalesced explicitly. SQL's + `NULL + n = NULL` would otherwise let one unrecorded sample null out an entire + session's total (AC-12). +- Every token and cost column is 64-bit (`BIGINT`), never a 32-bit `INTEGER`. + This is not defensive sizing: a single sampled turn reports 8,203,943 + cached-read tokens, and the merged per-session cached-input total has been + measured at 98,805,109 on one already-completed task. On Postgres `INTEGER` + is `int4` (ceiling 2,147,483,647), so a long-running session would abort the + insert and take the whole transaction with it. SQLite's `INTEGER` is already + 64-bit; the widening exists for Postgres parity. +- `occurred_at` is the ingest instant in UTC, acquired **once, before the + first insert attempt**, and reused byte-for-byte by every AC-32 retry of that + event. It SHALL NOT be re-read on a later attempt: a retried event would + otherwise carry a timestamp later than events that arrived after it, which + breaks the `(occurred_at, id)` total order AC-16 promises and lets + `first_event_at` / `last_event_at` move under contention rather than under + real event arrival. It is deliberately NOT taken from the payload's + `timestamp` field: that value is producer-clock-derived and would let a + skewed or replayed producer reorder the ledger. `created_at` equals + `occurred_at` for a first write; the two are separate columns so a future + out-of-band import can carry a true event time without lying about when the + row was recorded. +- `contract_version` starts at `1` for every row written by this feature. It is + bumped only when a column's *meaning* changes, not when a column is added. +- The `rate_*` and `pricing_catalog_version` columns are populated only on the + `models_dev_list` path. They are recorded so a computed cost can be + re-derived and audited later; without them an `unpriced`-to-priced catalog + change is indistinguishable from a pricing bug. +- `tokens_cached_read` / `tokens_cached_write` are stored separately, not + pre-merged. Providers charge different rates for the two, and a merged value + cannot be decomposed afterwards. The merged sum exists only on the rollup. + +Foreign-key rationale (this deviates from the task brief's stated +`ON DELETE CASCADE from task_sessions`; see *Corrections to the task brief*): + +- `task_id` cascades from `tasks`. Deleting a task deletes its spend record, so + task deletion remains a complete deletion. +- `session_id` is `ON DELETE SET NULL`, matching + `task_step_transitions.session_id` and its stated reason: the historical fact + must survive deletion of the thing it referred to. A ledger row whose session + was pruned still contributes to its task's total. +- `turn_id` carries no foreign key, for the same reason. + +### `task_sessions` (existing columns, newly written in production) + +`cost_subcents`, `tokens_in`, `tokens_cached_in`, `tokens_out` already exist on +`task_sessions` and are already incremented atomically by the Office +subscriber. This feature makes the production ledger writer the sole writer of +those four columns (AC-10) and surfaces them on the session model +(`models.TaskSession`) and on `dto.TaskSessionDTO`, which they are not today. + +`dto.TaskSessionDTO` is the **only** DTO widened. The repo has a second, +`dto.TaskSessionSummaryDTO` - declared in the same file as "a lightweight +version of `TaskSessionDTO` without snapshot fields" - which backs the +`GET /tasks/:id/sessions` list projection +(`task/handlers/task_session_summary_projection.go`), the boot payload's +`Sessions` array, and the MCP `list_task_sessions` tool. It is deliberately +left unchanged, along with every projection built from it. Widening it would +add per-session cost to an agent-facing tool and to a list endpoint this card +never scoped, and the deliverable for querying cost is the HTTP read surface +below, not a field on a list row. Naming one DTO also settles which existing +API responses and frontend types change: exactly those carrying the full +`TaskSessionDTO`. + +Three of the four are declared `INTEGER`; only `tokens_cached_in` is `BIGINT` +(`base_schema.go`, and `base_migrations.go`'s `migrateSessionsAddCostColumns`). +On Postgres `INTEGER` is `int4`, and that migration's own comment records the +consequence: an overflow "would abort the single multi-column UPDATE in +`IncrementTaskSessionUsage` ... silently taking +`tokens_in`/`tokens_out`/`cost_subcents` down with it for that session". Under +AC-11 that abort would now roll back the ledger insert too, so a derived column +would destroy the source of truth. AC-28 widens all three, and the boundary +scenarios below deliberately name columns that can actually overflow rather +than the one already safe. + +## API surface + +Both routes register beside the existing task routes in +`internal/task/handlers` and are available regardless of `features.office`. + +``` +GET /api/v1/tasks/:id/usage +GET /api/v1/tasks/:id/sessions/:sessionId/usage +``` + +Response body (identical shape for both scopes): + +```json +{ + "scope": "task", + "scope_id": "…", + "tokens_in": 80, + "tokens_cached_read": 8203943, + "tokens_cached_write": 264177, + "tokens_out": 44979, + "tokens_thought": 0, + "tokens_total": 8513179, + "cost_subcents": 79118, + "event_count": 1, + "estimated_event_count": 0, + "unpriced_event_count": 0, + "output_tokens_complete": true, + "first_event_at": "2026-08-23T04:15:58Z", + "last_event_at": "2026-08-23T04:15:58Z" +} +``` + +- `output_tokens_complete` is false when any contributing row has an unrecorded + output count, marking `tokens_out` as a lower bound (AC-12, AC-19). It is + `true` for a scope with no rows at all (AC-20). +- `tokens_total` is the sum of the stored per-row `tokens_total`, which the + writer computed from the per-kind counts (AC-23). It is never recomputed at + read time, and all five summed kinds are reported beside it, so it cannot + disagree with them (AC-19). +- `tokens_thought` is reported because AC-23 includes it in `tokens_total`. It + is zero for most models and non-zero for reasoning-heavy ones; it is never + priced (AC-7). +- `estimated_event_count` and `unpriced_event_count` let a caller distinguish a + confident total from a partial one without reading raw rows. +- Timestamps are null when `event_count` is 0. +- Response codes: `200` with zeroed totals for a known scope with no usage + (AC-20); `404` when the task or session does not exist; `404` when the + session exists but does not belong to the task in the path. + +The task-scoped total sums every ledger row for the task, including rows whose +`session_id` has been set to NULL by session deletion. The session-scoped total +sums only rows still bound to that session. + +No new WebSocket contract. The existing `session.prompt_usage` frame is +unchanged; this feature does not alter what the frontend receives today. + +## State machine + +A usage event has a single, terminal lifecycle. There is no draft, no pending, +and no reconciliation state. + +| From | Trigger | To | Actor | +|---|---|---|---| +| (none) | usage event observed, all required identifiers present | `recorded` | ledger writer | +| (none) | usage event observed, identifier already present | `dropped:duplicate` | ledger writer | +| (none) | usage event observed, task identifier missing | `dropped:unattributable` | ledger writer | +| (none) | usage event observed, session identifier missing | `recorded` with null `session_id`, no rollup | ledger writer | +| (none) | usage event observed, turn identifier missing | `recorded` with null `turn_id` | ledger writer | +| (none) | usage event observed, a token or cost value is negative | `dropped:invalid` | ledger writer | +| (none) | usage event observed, invalid **and** session/task mismatched (AC-27, AC-33) | `dropped:invalid` | ledger writer | +| (none) | usage event decoded, worker queue full (AC-34) | `dropped:overflow` | ledger writer | +| (none) | usage event still buffered when the shutdown drain deadline expires (AC-34) | `dropped:drain_timeout` | ledger writer | +| (none) | usage event decoded, shutdown drain already begun (AC-34) | `dropped:shutdown` | ledger writer | +| (none) | usage event in flight when the shutdown drain deadline expires (AC-34) | `dropped:error`, transaction rolled back, nothing written after the drain returns | ledger writer | +| (none) | usage event observed, no task identifier **and** a negative value (AC-27) | `dropped:unattributable` | ledger writer | +| (none) | usage event observed, `usage_event_id` missing or empty | `dropped:invalid` | ledger writer | +| (none) | bus payload fails to decode | `dropped:decode_error` | ledger writer | +| (none) | usage event observed, its session row does not exist | `recorded` with null `session_id`, no rollup | ledger writer | +| (none) | usage event observed, its session row belongs to a different task (AC-33) | `recorded` with null `session_id`, no rollup | ledger writer | +| (none) | usage event redelivered with a randomly minted identifier (AC-22) | `recorded` as a new row, rollup applied | ledger writer | +| (none) | insert fails transiently (AC-32) | retried unchanged, up to three attempts; then `recorded` or `dropped:error` | ledger writer | +| (none) | insert fails on the `usage_event_id` unique constraint (AC-32) | `dropped:duplicate`, transaction rolled back, no rollup increment | ledger writer | +| (none) | insert fails on a foreign key (AC-32) | retried once with null `session_id`; then `recorded` without rollup, or `dropped:error` | ledger writer | +| (none) | insert or rollup fails for any other reason | `dropped:error`, transaction rolled back | ledger writer | +| `recorded` | task deleted | row removed by cascade | database | +| `recorded` | session deleted | `session_id` cleared, row retained | database | + +A retry is not a state: only the outcome of the final attempt is a transition, +so a retried event still increments exactly one counter. + +`recorded` has no outgoing transition that changes the row's contents (AC-3). + +Every terminal transition above increments exactly one AC-27 counter: a +`recorded` row bumps `task_usage_events_written_total`, and each `dropped:` +bumps `task_usage_events_dropped_total` under `reason=`. The stage order is +AC-27's - decode, admit, validate, ownership, insert - so the redelivery of an +invalid event is counted `invalid` rather than `duplicate`, because it never +reaches the insert where uniqueness is enforced, and an event that is both +invalid and mismatched is counted `invalid` rather than recorded with a null +session. Within validate, the fixed sub-check order makes an event that is both +unattributable and negative count `unattributable`. + +## Permissions + +Usage totals are visible to any caller authorized to read the task itself; they +introduce no new authorization tier. Where `features.auth` is enabled, both +routes follow the same workspace-scoped rule as the sibling +`GET /api/v1/tasks/:id/sessions` route. No caller can write to the ledger over +HTTP; the only writer is the internal event subscriber. + +## Failure modes + +| Condition | Behaviour | +|---|---| +| Decoded payload carries no task id | Row dropped, counted `unattributable`, warning logged. There is nothing to attribute the spend to and `task_id` is `NOT NULL`. Distinct from "task row absent" below, where the id is present and the row is gone. Defensive only: no current producer can reach it, since `publishPromptUsage` sets `task_id` alongside the session id it has already required to be non-empty. | +| Decoded payload carries a task id but no session id | Row recorded with `session_id = NULL` and no rollup increment, reaching the same end state as "session row absent" below. The spend is kept; only per-session grouping is lost. Dropping it would contradict the same principle applied to a missing `turn_id` (AC-17) and to an absent session row. Also unreachable from the current producer, which returns early on an empty session id (AC-24). | +| Payload's `session_id` names a session belonging to a different task | Row recorded under the payload's `task_id` with `session_id = NULL` and no rollup increment, warning logged (AC-33). Same end state as an absent session row, by design. | +| `usage_event_id` already present | Row dropped, counted as `duplicate`. Not an error; no rollup increment. Redelivery is expected, not exceptional. | +| Rollup increment fails after ledger insert | Whole transaction rolls back. No row, no increment. A later redelivery of the same event retries cleanly instead of colliding with a half-landed row. | +| Session row absent when the rollup runs | The writer records the row with `session_id = NULL` and applies no rollup increment; that transaction contains the insert alone, which satisfies AC-11 vacuously. Task attribution and the spend record survive, and only per-session grouping is lost. This is the same end state as a session deleted afterwards (`ON DELETE SET NULL`), so the two are deliberately indistinguishable. The earlier form of this row - insert with a non-null `session_id` and let the rollup match zero rows - is impossible: the DSN sets `_foreign_keys=on` (`internal/db/sqlite.go`), so that insert is rejected outright rather than committing. | +| Session deleted between the writer's read and its commit | The insert violates the `session_id` foreign key, classified by `IsForeignKeyViolation` per AC-32. The writer retries exactly once with `session_id = NULL` and no rollup, reaching the row above. A second failure is counted `error`. | +| Insert fails with a transient error (SQLite `SQLITE_BUSY` / `SQLITE_LOCKED`, Postgres serialization failure or deadlock) | The whole insert-plus-rollup transaction is re-run in a fresh transaction per attempt, at most three attempts, waiting 50 ms then 100 ms, keeping `session_id` and the rollup increment (AC-32). Retrying the INSERT statement alone inside the aborted transaction would fail with `25P02` on Postgres instead of retrying. Only exhausted attempts are counted `error`. This is what makes the two-concurrent-events guarantee in *Concurrency* hold under real contention rather than only in its absence, and it is why a transient failure must never take the foreign-key path. | +| Task row absent or deleted mid-write | The `task_id` foreign key rejects the insert. AC-32's single foreign-key retry, which nulls `session_id`, is rejected for the same reason, so the row is dropped and counted `error`. A ledger row is never written without a task to attribute it to - and reaching that outcome needs no session-versus-task classifier, only the retry. | +| Pricing lookup unavailable, times out, or misses | Row recorded `unpriced` with zero cost (AC-8). Never blocks, never drops, never retries inline. "Times out" is bounded by the **writer's own 2-second context deadline**, not by the lookup's, whose shipped default is 30 seconds (AC-8, AC-26). Unbounded, the single serial worker stalls per event and the queue fills behind it, turning an outage that should produce `unpriced` rows into `dropped:overflow`. | +| Pricing lookup unwired (Office off today) | Same as a miss: `unpriced`. Wiring the lookup outside the Office gate is in scope and its target is named in AC-26; a missing lookup must degrade, not fail. | +| Provider reports a negative token or cost value | Row dropped, counted as `invalid`, warning logged. Negative values are never written; an append-only ledger has no correcting update. | +| Database unavailable | Insert fails and is not transient-retryable; event dropped and counted `error`. The agent turn is unaffected: usage recording never blocks or fails an agent run. | +| Worker queue full when an event is decoded | Event abandoned rather than waited on, counted `overflow`, warning logged with the task id (AC-34). Blocking instead would push backpressure onto the orchestrator's streaming goroutine, which is the one thing this writer may never do. | +| Process shuts down with events still buffered | The writer stops accepting, drains the buffer, and returns when it is empty or after 5 seconds (AC-34). Events still buffered at the deadline are counted `error` and logged. Bounded on purpose: an unbounded drain would hang shutdown behind a wedged database. | +| Event arrives after the shutdown drain has begun | Refused at the admit stage rather than buffered, counted `shutdown`, logged; the callback still returns immediately (AC-34). A reason distinct from `overflow`, so a clean restart is not read as sustained backpressure. | +| Event in flight when the drain deadline expires | Its context is cancelled, the transaction rolls back, it is counted `error`, and nothing is written after the drain returns (AC-34). Committing into a pool the process is closing is a use-after-close, not a late row. | +| Insert rejected by the `usage_event_id` unique constraint | Counted `duplicate`, not `error`; no retry, no rollup increment (AC-32, AC-14). Classified by a constraint-specific detector, so a unique violation raised by any other constraint takes the `error` path instead of being silently swallowed as expected behaviour. | +| Ledger writer stops silently | The AC-27 expvar counters `task_usage_events_written_total` and `task_usage_events_dropped_total` are exported at `/debug/vars`, so a stopped writer is observable rather than presenting as "this task was free". | +| Producer stops publishing, or a turn reports no usage object | Not observable through this feature, by construction: the event never reaches the subscriber, so neither AC-27 counter moves and no row is written. AC-24 states the publisher's real precondition and AC-27 scopes its counters to the subscriber. This bounds what the counters prove - they show the writer is alive, never that a task's recorded spend is complete. | + +The overriding rule: **recording usage is never allowed to affect the agent +run.** Every failure above degrades the record, not the work. AC-34 is what +makes that rule structural rather than aspirational: because the bus delivers +to subscribers synchronously on the publisher's goroutine, the guarantee holds +only while the writer hands off and returns. Every retry, backoff and database +wait above happens on the writer's own worker. + +## Persistence guarantees + +- Ledger rows survive process restart, agent crash, session deletion, and + Office being toggled on or off. They are removed only when their task is + deleted. +- Rollup columns survive restart and are recomputable from the ledger at any + time, because the ledger is the source of truth and the rollup is purely + derived. This holds only once AC-29 removes + `BackfillSessionTokensCachedIn`, which today reassigns `tokens_cached_in` + from `office_cost_events` on every boot, ungated: with that code in place the + guarantee is false in exactly the install this feature targets, and it fails + on the second boot rather than the first, so any test that starts the backend + once will not see it. +- Nothing about this feature is cached in memory across a restart. A restart + mid-turn loses only usage events not yet observed; already-committed rows are + unaffected. +- No retention policy, TTL, or compaction. The ledger grows monotonically with + usage for the life of the task. +- Usage recorded before this feature ships is not backfilled. Historical turns + carry `prompt_usage` in `task_session_turns.metadata`; that data is left where + it is and is not migrated. + +## Concurrency + +- Two events for the same session arriving concurrently each take their own + transaction. The rollup is applied as a relative increment + (`SET col = col + ?`), never a read-modify-write, so concurrent increments + compose and no update is lost. +- Two deliveries of the *same* event racing each other, where the producer + minted a deterministic identifier: exactly one commits; the other fails the + `uniq_task_usage_events_usage_event_id` constraint at insert and is dropped + as a duplicate through AC-32's unique-violation branch (AC-14), not as an + error. The rollup is incremented exactly once, because the loser's increment + shares the loser's rolled-back transaction. This is the collision the + constraint actually has to resolve: no pre-insert read can, since both + deliveries may read before either writes (AC-32). Where the producer minted a random identifier (AC-22) the two + deliveries carry different keys, so both commit and the rollup is incremented + twice. That is the honest outcome of a producer contract this card does not + change, not a race this card loses. +- A read of task totals concurrent with an insert returns either the + pre-insert or the post-insert total, never a torn total, because the insert + and its rollup share one transaction. +- Ordering under concurrency is by `(occurred_at, id)`; two rows with an + identical `occurred_at` are ordered by the monotonic `id`, so the total order + is deterministic and stable across repeated reads. + +## Scenarios + +Golden path + +- **GIVEN** a Kanban-only installation with `features.office` disabled, **WHEN** + an agent turn completes and reports usage, **THEN** a `task_usage_events` row + exists for that turn and the session's `cost_subcents` is greater than zero. +- **GIVEN** a recorded usage event with a provider-reported cost, **WHEN** the + row is read, **THEN** `cost_source` is `provider_reported` and + `cost_subcents` equals the provider's amount, and no pricing lookup was + performed. +- **GIVEN** a task with three recorded usage events, **WHEN** + `GET /api/v1/tasks/:id/usage` is called, **THEN** the response + `event_count` is 3 and `cost_subcents` equals the sum of the three rows. + +Cache-heavy turn (the dominant real shape) + +- **GIVEN** a usage event reporting `input_tokens=80`, + `cached_read_tokens=8203943`, `cached_write_tokens=264177`, + `output_tokens=44979`, **WHEN** the row is recorded, **THEN** + `tokens_cached_read` and `tokens_cached_write` are stored separately and the + session rollup's `tokens_cached_in` increases by 8,468,120. + +Multiple events per turn + +- **GIVEN** a turn that emits five usage events over fifteen minutes, **WHEN** + all five are observed, **THEN** five ledger rows exist sharing one `turn_id`, + and the per-turn total is their sum. +- **GIVEN** two ledger rows sharing `(session_id, turn_id)`, **WHEN** the ledger + is written, **THEN** no uniqueness constraint is violated. +- **GIVEN** a usage event carrying no turn identifier, **WHEN** it is observed, + **THEN** a row is recorded with a null `turn_id`, the session rollup is + incremented, and the row is included in the task total. + +Boundaries and ordering + +- **GIVEN** a session whose cumulative **output** tokens exceed 2,147,483,647 - + a column declared `INTEGER` before this feature - **WHEN** a further event is + recorded, **THEN** the ledger row and the rollup increment both commit on + Postgres as well as SQLite (AC-28). Run against the pre-widening schema on + Postgres this scenario MUST fail; a version of it that passes before the + migration is not testing the migration. +- **GIVEN** a session whose cumulative `cost_subcents` exceeds 2,147,483,647 + (about $214,748 of recorded spend), **WHEN** a further event is recorded, + **THEN** both writes commit on Postgres, and the ledger row is not lost to a + rollup-column overflow. +- **GIVEN** a session whose cumulative cached-input tokens exceed 2,147,483,647, + **WHEN** a further event is recorded, **THEN** the insert and the rollup both + succeed on Postgres as well as SQLite. +- **GIVEN** two ledger rows with an identical `occurred_at`, **WHEN** the ledger + is read twice, **THEN** both reads return them in the same order. +- **GIVEN** two usage events for the same session processed concurrently, + **WHEN** both commit, **THEN** the session rollup reflects both, with neither + increment lost. + +Idempotency + +- **GIVEN** a usage event already recorded whose identifier was derived + deterministically from `(session_id, execution_id, prompt_generation)`, + **WHEN** the identical event is redelivered, **THEN** no second row is + written, the session rollup is unchanged, and the drop is counted as a + duplicate rather than surfaced as an error. +- **GIVEN** a usage event published with no `execution_id` or no + `prompt_generation`, so `usageEventIDFor` minted a random identifier, **WHEN** + the identical event is redelivered, **THEN** a second row IS written with a + different identifier and the rollup IS incremented twice (AC-22). This is the + documented behaviour of the producer contract, and a test asserting the + opposite is asserting something production does not do. +- **GIVEN** a usage event arriving with an empty `usage_event_id`, **WHEN** it + is observed, **THEN** no row is written and the drop is counted as invalid. +- **GIVEN** an event that is both a redelivery and carries a negative token + count, **WHEN** it is observed, **THEN** the drop is counted as `invalid`, + not `duplicate` (AC-27). +- **GIVEN** two deliveries of the same event bearing the same deterministic + identifier, whose inserts are made to overlap so both pass any pre-insert + check and race at the constraint, **WHEN** both are processed, **THEN** + exactly one row exists, the rollup was incremented exactly once, and the + loser is counted `duplicate` and not `error` (AC-13, AC-14, AC-32, + *Concurrency*). Only a genuine insert-time collision exercises AC-32's + unique-violation branch; a redelivery arriving after the first has committed + is served by any pre-insert read and proves nothing about it. +- **GIVEN** a unique violation raised by a constraint other than + `uniq_task_usage_events_usage_event_id` - on Postgres a `23505` naming a + different constraint, on SQLite a `UNIQUE constraint failed:` message naming + a different column - **WHEN** it is classified, **THEN** it is NOT counted + `duplicate` and takes AC-32's "anything else" branch. A detector matching a + bare `23505`, or the bare substring `UNIQUE constraint failed`, passes the + scenario above and fails this one, which is why the prior contract row in + `docs/specs/tasks/external-id-idempotency/spec.md` exists. +- **GIVEN** an event that carries no `task_id` **and** a negative token count, + **WHEN** it is validated, **THEN** the drop is counted `unattributable`, not + `invalid` (AC-27). The validate stage's sub-checks share labels with each + other except for this pairing, so it is the only one whose ordering is + observable. + +Estimation and pricing + +- **GIVEN** a usage event with no provider-reported cost and a model absent from + the pricing catalogue, **WHEN** the row is recorded, **THEN** + `cost_source` is `unpriced`, `cost_subcents` is 0, and the token counts are + still recorded. +- **GIVEN** a task whose events include one `unpriced` row, **WHEN** task totals + are read, **THEN** `unpriced_event_count` is 1, so the caller can see the + total understates actual spend. +- **GIVEN** a usage event whose adapter could not observe output tokens, + **WHEN** task totals are read, **THEN** `output_tokens_complete` is false. +- **GIVEN** a provider that explicitly reports a cost of zero with its presence + flag set, **WHEN** the row is recorded, **THEN** `cost_source` is + `provider_reported` with `cost_subcents` 0, and no pricing estimate replaces + it. + +Deletion and durability + +- **GIVEN** a task with recorded usage, **WHEN** its session is deleted, + **THEN** the ledger rows remain, their `session_id` is null, and the task's + total is unchanged. +- **GIVEN** a task with recorded usage, **WHEN** the task is deleted, **THEN** + its ledger rows are removed. +- **GIVEN** recorded usage in an installation with `features.office` disabled, + **WHEN** the backend restarts **twice**, **THEN** every row and every rollup + value is unchanged after each boot, `tokens_cached_in` included (AC-29). One + restart is not enough: the defect this covers is a boot-time reassignment, so + a test that starts the backend once passes with the bug present. Run this + against a build that still calls `BackfillSessionTokensCachedIn` and it MUST + fail with `tokens_cached_in` at zero; a version that passes before the removal + is not testing the removal. + +Failure and degradation + +- **GIVEN** a usage event with an empty task id, **WHEN** it is observed, + **THEN** no row is written and the drop is counted as unattributable. +- **GIVEN** a usage event reporting a negative output-token count, **WHEN** it + is observed, **THEN** no row is written and the drop is counted as invalid. +- **GIVEN** the rollup increment fails, **WHEN** the event is processed, + **THEN** no ledger row is committed for that event. +- **GIVEN** the pricing lookup is not wired, **WHEN** an event without a + provider-reported cost is observed, **THEN** the row is recorded `unpriced` + and the agent turn completes normally. +- **GIVEN** an insert that fails with a transient contention error twice and + succeeds on the third attempt, **WHEN** the event is processed, **THEN** the + row is recorded with its `session_id` intact and its rollup applied, and no + drop is counted (AC-32). +- **GIVEN** an insert that fails with a transient contention error on every + attempt, **WHEN** the event is processed, **THEN** no row is written, exactly + one `reason=error` drop is counted, and no row was ever written with a nulled + `session_id` (AC-32). A transient failure taking the foreign-key path is the + specific regression this covers. +- **GIVEN** an insert that fails because the task row is gone, **WHEN** the + event is processed, **THEN** the single foreign-key retry fails for the same + reason, no row is written, and exactly one `reason=error` drop is counted + (AC-32). +- **GIVEN** an agent turn that reports no usage object, **WHEN** the turn + completes, **THEN** no event is published, no row is written, and neither + AC-27 counter changes. This is the specified outcome, not a defect: the + producer's precondition is stated in AC-24 and the counters are scoped to the + subscriber in AC-27. + +Read surface + +- **GIVEN** a task that has never run an agent, **WHEN** its usage is read, + **THEN** the response is `200` with all totals zero, `event_count` 0, and + null timestamps. +- **GIVEN** a session id that belongs to a different task, **WHEN** it is read + under this task's path, **THEN** the response is `404`. + +Session attribution and durability under a missing session + +- **GIVEN** a usage event whose `session_id` names a session row that does not + exist, **WHEN** it is observed, **THEN** a ledger row is recorded with a null + `session_id`, no rollup increment is applied, and the row still counts toward + the task total. +- **GIVEN** a usage event whose `session_id` names a session belonging to a + different task, **WHEN** it is observed, **THEN** a row is recorded under the + payload's `task_id` with a null `session_id`, no rollup increment is applied + to either session, and a warning is logged (AC-33). +- **GIVEN** a usage event carrying a task id and an empty session id, **WHEN** + it is observed, **THEN** a row is recorded with a null `session_id` and no + rollup, rather than dropped as unattributable. Only a missing **task** id is + unattributable. + +Aggregation and observability + +- **GIVEN** a row whose `tokens_out` is *not recorded* (NULL) and whose other + counts are present, **WHEN** the session rollup and the task totals are read, + **THEN** the NULL contributes zero, neither total is null, and + `output_tokens_complete` is false (AC-12, AC-19). This is the reachable NULL + path: the wire carries a presence bit for output tokens only (AC-30). +- **GIVEN** a row inserted directly with `tokens_cached_read` NULL - which the + current producer cannot generate, so this is forward-compatibility cover for a + transport that later carries presence bits (AC-30) - **WHEN** the **task and + session total reads** are performed, **THEN** the NULL contributes zero and + neither total is null (AC-12). The scope is the read aggregation only: a row + inserted directly runs no rollup increment, so this scenario cannot assert + anything about the increment's coalesce, and a test written as though it + could would pass against a writer whose increment path has no coalesce at + all. The increment side of AC-12 is covered on the reachable NULL path, + through `tokens_out`, in the scenario above. +- **GIVEN** a usage event whose payload omits `cached_read_tokens` entirely, + **WHEN** it is observed, **THEN** the row records `0`, not NULL, because the + wire cannot distinguish absence from a measured zero for that field (AC-30). +- **GIVEN** a task with rows whose per-kind counts are known **and at least one + row reporting non-zero `tokens_thought`**, **WHEN** totals are read, **THEN** + `tokens_total` equals `tokens_in + tokens_cached_read + tokens_cached_write + + tokens_out + tokens_thought` as reported in that same response, with all five + present in the body (AC-19, AC-23). A fixture whose thought tokens are all + zero passes even when the response omits the field, so it does not test this. +- **GIVEN** a usage event that is dropped as unattributable, **WHEN** + `/debug/vars` is read, **THEN** `task_usage_events_dropped_total` carries a + `reason=unattributable` entry, and a successfully recorded event increments + `task_usage_events_written_total` under its `source` and `provider` (AC-27). +- **GIVEN** a usage event whose `agent_type` is `claude-acp` and whose `model` + is the alias `sonnet`, **WHEN** it is observed, **THEN** the row records + `provider` as `anthropic`, and the written counter carries + `provider=anthropic` (AC-31). A model-prefix match alone resolves this to the + empty string, so a fixture using a full model name would not detect the loss + of the CLI tiers. +- **GIVEN** an installation with `features.office` enabled and one usage event, + **WHEN** both the ledger writer and the Office subscriber derive a provider + for it, **THEN** the two values are identical, because both call the helper + extracted by AC-31. +- **GIVEN** the pricing catalogue is refreshed between two lookups, **WHEN** an + event is priced, **THEN** the row's `rate_*_per_million` values and its + `pricing_catalog_version` come from the same snapshot, so the recorded cost + is reproducible from the recorded rates (AC-26). A writer that reads rates + and version through two separate calls fails this. + +Execution model, identity, and ordering + +- **GIVEN** the ledger writer's database is made to block on insert, **WHEN** a + prompt-usage event is published on the bus, **THEN** `Publish` returns + without waiting for the write (AC-34). A handler that does its work on the + callback goroutine fails this, and it is the only assertion that distinguishes + the two designs: both record the same row eventually. +- **GIVEN** a worker queue already holding its 1024-event capacity, **WHEN** + another event arrives, **THEN** it is abandoned rather than waited on and + `task_usage_events_dropped_total` carries a `reason=overflow` entry + (AC-34, AC-27). +- **GIVEN** events buffered but not yet written, **WHEN** the process shuts + down, **THEN** the buffered events are drained and recorded, and shutdown + returns within the 5-second deadline even if the database never responds + (AC-34). Asserting only that buffered events are recorded would pass against + an unbounded drain that hangs shutdown. +- **GIVEN** the writer's drain has begun, **WHEN** the bus publishes another + prompt-usage event, **THEN** the callback returns without blocking, no row is + written, and `task_usage_events_dropped_total` carries a `reason=shutdown` + entry distinct from `reason=overflow` (AC-34, AC-27). +- **GIVEN** an event in flight against a database that never responds, **WHEN** + the 5-second drain deadline expires, **THEN** the drain returns, that event + is counted `dropped:error`, and no ledger row or rollup increment appears + afterwards (AC-34). A writer that lets the in-flight write continue past the + deadline passes the buffered-drain scenario above and fails this one. +- **GIVEN** a pricing lookup that never returns, **WHEN** an event is priced, + **THEN** the row is recorded `unpriced` with zero cost within the writer's + 2-second deadline and the queue does not reach its 1024-event capacity (AC-8, + AC-26, AC-34). Asserting only that the row is `unpriced` passes against a + writer that waits out the lookup's own 30 seconds. +- **GIVEN** two usage events published in a known order, **WHEN** + `ListTaskUsageEvents` is called, **THEN** they are returned in that same + order, and a repeated call returns an identical sequence (AC-16, AC-34). A + goroutine-per-event writer can fail this intermittently, so the fixture SHALL + publish enough events to make an ordering inversion observable rather than + relying on two. +- **GIVEN** a task with no ledger rows, and separately a task id that does not + exist, **WHEN** `ListTaskUsageEvents` is called for each, **THEN** both + return an empty slice and a nil error (AC-16). +- **GIVEN** an insert that fails transiently twice and succeeds on the third + attempt, **WHEN** the event is recorded, **THEN** the row's `occurred_at` is + the instant acquired before the first attempt, not the third (AC-32, AC-16). + A fixture whose retries complete inside the timestamp's resolution cannot + observe this and SHALL force a measurable gap. +- **GIVEN** a usage event carrying a non-empty `model`, `agent_type` and + `agent_profile_id`, **WHEN** it is recorded, **THEN** all three appear on the + row with those values (AC-35). A writer that defaults all three to `''` + passes every other criterion, so this is the only assertion that catches it. +- **GIVEN** a usage event that is both invalid and whose session belongs to a + different task, **WHEN** it is processed, **THEN** it is dropped as `invalid` + with no row written and no ownership lookup performed (AC-27, AC-33). An + implementation that checks ownership first records a null-session row + instead, which is a different observable outcome. + +Office coexistence + +- **GIVEN** an installation with `features.office` enabled, **WHEN** an agent + turn reports usage, **THEN** the session rollup reflects that turn exactly + once, not twice. +- **GIVEN** an installation with `features.office` enabled, **WHEN** a turn + reports usage, **THEN** both an `office_cost_events` row and a + `task_usage_events` row exist for it, and the `task_sessions` rollup was + incremented by the ledger writer only (AC-21). Asserting the rollup value + alone is not sufficient: an Office subscriber that still increments and a + ledger writer that does not would produce the same number. + +## Corrections to the task brief + +Recorded because they change the contract, and each is backed by evidence taken +from current code and a live database (503 sessions / 5,664 turns / 1,962 +existing cost rows). + +1. **"Nothing persists token usage per task run outside Office" is only half + true.** `internal/orchestrator/event_handlers_streaming.go` + (`persistPromptMetadataOnTurn`) already writes a `prompt_usage` object into + `task_session_turns.metadata` on every turn, unconditionally — 839 turns + carry it in the sampled database. What is missing in production is *cost* + and *queryability*, not token capture. The gap this spec closes is a + queryable, priced, append-only record. + +2. **`(session_id, turn_id)` is not unique and cannot be the ledger key.** In + the sampled ledger, 811 turns have one event, 4 have two, 1 has three, and 1 + has five — a single turn spanning 10:33 to 10:48 with five distinct + provider-reported costs. This follows from the design: the idempotency key is + derived from `(session, execution, prompt_generation)`, and one turn contains + many prompt generations. The unique key is `usage_event_id` (827 rows, 827 + distinct values); `(session_id, turn_id)` is an aggregation index (AC-13, + AC-15). + +3. **`FK ON DELETE CASCADE from task_sessions` would defeat the durability + requirement.** The brief asks for an append-only durable record and for a + cascade that erases it when a session is pruned. This spec follows the + repository's own append-only-log precedent instead: + `task_step_transitions.session_id` is `ON DELETE SET NULL`, explicitly + because "the historical fact ... must survive that deletion". The live + `office_cost_events` table already holds 110 rows whose session no longer + exists. Cascade is kept on `task_id`, so task deletion is still complete. + +4. **`tokens_in` / `tokens_out` alone would misreport real turns by orders of + magnitude.** A sampled turn reports `input_tokens=80` against + `cached_read_tokens=8,203,943` and a provider-reported cost of $7.91. Cache + token classes are first-class ledger columns, stored unmerged. + +5. **`cost_usd` is not stored.** The established unit across + `office_cost_events`, `task_sessions`, and the adapter wire type is integer + subcents. Introducing a float dollar column would make sums + non-associative and would disagree with the columns it must reconcile + against. + +6. **`internal/agent/usage` is confirmed unrelated**, as the brief states: it + probes provider plan quota (Claude OAuth usage endpoint, Codex auth file) and + is specified by [costs/subscription-usage](../costs/subscription-usage.md). + It is untouched here. + +7. **Scope note, per the brief's instruction to flag growth.** The single + genuinely larger option is converging this ledger with Office's + `office_cost_events` into one table. That is deliberately *not* attempted + here (see *Out of scope*); the no-double-count rule (AC-10, and the Office + coexistence scenario) bounds the cost of deferring it. Everything else fits + the brief's stated scope. + +## Out of scope + +- **Converging with `office_cost_events`.** Office keeps its own ledger, budget + policies, and cost explorer unchanged. Migrating Office's eight aggregation + queries and 1,962 existing rows onto this table is a follow-up. + + This card **does** make one narrow amendment to + [office/costs](../office/costs.md), and Build must land it in the same change. + A spec cannot remove a writer while leaving another document asserting that + writer in place, so the passages there stating that the Office subscriber + maintains the `task_sessions` rollup are superseded by AC-10 and AC-21: the + sentence that `cost_subcents`, `tokens_in`, `tokens_out` and + `tokens_cached_in` are incrementally updated as cost events arrive; the + `recordCostEventAndRollup` atomicity paragraph; the `TaskSession ... running + totals` line; and the redelivery scenario that asserts a rollup increment. + Everything else in that document - Office's own `office_cost_events` + contract, its budgets, and the two-layer cost model this spec reuses - is + unchanged and remains frozen. +- **Making generation-less transports deduplicable.** `usageEventIDFor` mints a + random identifier when `execution_id` or `prompt_generation` is absent, and + those rows are not deduplicated (AC-22). Changing that minting rule would + change the orchestrator's published contract and a behaviour + [office/costs](../office/costs.md) documents as intentional, so it is a + follow-up rather than part of this card. The consequence is named in AC-22 and + in the idempotency scenarios rather than left for a reader to discover. +- **Widening the prompt-usage publisher's precondition.** `publishPromptUsage` + returns early when the session id is empty, when the event bus is nil, or when + the usage object is nil, so a turn reporting no usage is never published and + is invisible to this ledger (AC-24). Publishing those turns would change the + orchestrator's published contract, which this card does not touch, and it + would mean deciding what a usage-less turn should record - a question with no + answer the ledger can supply. The consequence is named in AC-24, in AC-27's + scoping of its counters, in the failure-modes table, and in a scenario, rather + than left for a reader to discover. +- **A dedicated boot-time reconciliation of the rollup against the ledger.** + AC-29 removes the one that exists because it destroys data; it does not + replace it. The rollup is maintained transactionally on every write (AC-11) + and is recomputable from the ledger on demand, so a periodic or startup + reconciler has nothing to correct and would be a third writer again. +- **Backfilling or recomputing the rollup** for sessions written before AC-21 + removes Office's increment. Existing values stay as they are; the ledger is + authoritative from its first row forward, and AC-12's equality is a forward + guarantee, not a claim about pre-existing rows. +- **Budgets, limits, or enforcement.** This ledger records; it does not cap, + warn, throttle, or block. Per-period budgets remain an Office feature. +- **A cost UI.** No dashboard, chart, or task-detail cost panel. The frontend + store already has an unrendered `promptUsage` slice; this feature does not + render it. The deliverable is the HTTP read surface. +- **Backfilling historical usage** from `task_session_turns.metadata` or from + `office_cost_events`. +- **Subscription-plan spend.** Flat-fee plans report no per-token cost; those + rows record tokens with `cost_source = unpriced`. Utilization tracking is + [costs/subscription-usage](../costs/subscription-usage.md). +- **Per-turn or per-task cost attribution across sub-agents.** Sub-agent turns + are attributed to the session that spawned them, as today. +- **Currency other than USD**, currency conversion, and any billing, invoicing, + or payment integration. +- **Retention, archival, or compaction** of ledger rows. +- **Listing raw ledger rows over HTTP**, and any pagination or cursor contract + for them. The read surface returns totals only; raw rows are reachable from + the database and from the internal repository, not from the API. +- **Widening `dto.TaskSessionSummaryDTO`** or any projection built from it - + the `GET /tasks/:id/sessions` list, the boot payload's `Sessions` array, and + the MCP `list_task_sessions` tool. Only the full `dto.TaskSessionDTO` gains + the four rollup fields. Adding per-session cost to a list row and to an + agent-facing tool is a surface decision this card did not scope, and the + deliverable for querying cost is the HTTP read surface. +- **Changing when `orchestratorSvc.SetModelInfoLookup` runs.** AC-26 moves the + models.dev *construction* out of the `features.office` gate and nothing else; + that setter, which feeds the orchestrator's context-window fallback, stays + gated exactly as today. Ungating context-window fallback for every install is + a separate behaviour change with its own review. +- **Tuning the AC-32 backoff or the AC-34 queue and drain constants** at + runtime. The values are fixed in code: 50 ms/100 ms backoff, a 1024-event + buffer, a 5-second drain deadline. Making them configurable adds a + configuration surface before any evidence exists that the defaults are wrong. +- **Aggregation surfaces beyond task and session scope** — by model, by + provider, by agent profile, by period. The columns support them; the routes + are not part of this iteration.