From c1568319d754d8ab60afea12681626db8a547c9c Mon Sep 17 00:00:00 2001 From: Aleks Petrov Date: Mon, 27 Apr 2026 13:24:57 +0200 Subject: [PATCH] feat(memory): wire usage_events table from dispatcher (GH-2429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The usage_events table was provisioned and the metering writers were implemented, but no production code emitted events — leaving the billing/per-user-cost pipeline dead. The dispatcher worker now calls RecordTaskUsage after persisting execution metrics so each completed execution emits task + token + compute events. Also adds a UserID field to Execution for future multi-tenant rollups; empty in single-tenant deployments. --- .agent/tasks/gh-2429.md | 60 +++++++++++++++++++++++++++++++++ .claude/settings.json | 4 +-- internal/executor/dispatcher.go | 14 ++++++++ internal/memory/store.go | 4 +++ 4 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 .agent/tasks/gh-2429.md diff --git a/.agent/tasks/gh-2429.md b/.agent/tasks/gh-2429.md new file mode 100644 index 00000000..68f7eef1 --- /dev/null +++ b/.agent/tasks/gh-2429.md @@ -0,0 +1,60 @@ +# GH-2429 + +**Created:** 2026-04-27 + +## Problem + +GitHub Issue #2429: feat(memory): wire usage_events table — billing pipeline scaffolded but never called + +## Problem + +`usage_events` table is created on store init (`internal/memory/store.go:171-187`) and the `RecordUsageEvent` / `RecordExecutionUsage` writers exist in `internal/memory/metering.go`, but **no production code calls them**: + +```bash +$ grep -rn "RecordUsageEvent\|RecordExecutionUsage" internal/ cmd/ --include="*.go" | grep -v "_test.go\|metering.go" +# (no results) +``` + +Result: `SELECT COUNT(*) FROM usage_events` → **0 rows**, ever. The billing/metering pipeline is dead code. + +All cost data still lives in `executions.estimated_cost_usd` (legacy single-row-per-execution model), which: +- Doesn't separate task vs. token vs. compute vs. api_call event types (the whole reason `usage_events` was designed) +- Has no `user_id` (multi-tenancy can't aggregate per-user spend) +- Has no `metadata` field for per-event context (cache hit ratio, model variant, etc.) + +## Suggested fix + +In `internal/executor/dispatcher.go` worker loop (around line 669, where `SaveExecutionMetrics` is called): + +```go +if result != nil && result.TokensTotal > 0 { + if err := w.store.RecordExecutionUsage(exec.ID, exec.UserID, exec.ProjectPath, result); err != nil { + w.log.Error("Failed to record usage event", slog.Any("error", err)) + } +} +``` + +`RecordExecutionUsage` already exists at `metering.go:130-187` and emits 3 events per execution (task, token, compute). It just needs a caller. + +## Why it matters + +- Multi-user / team mode (per `team_members`, `teams` tables) needs per-user cost rollups. `usage_events.user_id` is the designed pivot. +- Future budget enforcement / billing export depends on this table. +- We currently can't answer "how much did GLM vs. Claude cost us this week" without parsing `executions.metadata`. + +## Verification + +After fix: run any task, then `SELECT * FROM usage_events ORDER BY timestamp DESC LIMIT 5` should show 3 rows per execution (task + token + compute event types). + +## Scope + +Small: ~1 call site in dispatcher + plumb `UserID` through `Execution` struct if missing. Tests already exist in `metering_test.go`. + +## Refs + +- `internal/memory/metering.go:110,130-187` +- `internal/memory/store.go:171-187` +- `internal/executor/dispatcher.go:669` + +## Acceptance Criteria + diff --git a/.claude/settings.json b/.claude/settings.json index 80dcec9c..1f2f8d43 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "/var/folders/_8/nnsxrdhn46x08pb886hrggv00000gn/T/pilot-hooks-3723655428/pilot-bash-guard.sh" + "command": "/var/folders/_8/nnsxrdhn46x08pb886hrggv00000gn/T/pilot-hooks-4040757811/pilot-bash-guard.sh" } ] } @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "/var/folders/_8/nnsxrdhn46x08pb886hrggv00000gn/T/pilot-hooks-3723655428/pilot-stop-gate.sh" + "command": "/var/folders/_8/nnsxrdhn46x08pb886hrggv00000gn/T/pilot-hooks-4040757811/pilot-stop-gate.sh" } ] } diff --git a/internal/executor/dispatcher.go b/internal/executor/dispatcher.go index ad16e168..935104c2 100644 --- a/internal/executor/dispatcher.go +++ b/internal/executor/dispatcher.go @@ -680,6 +680,20 @@ func (w *ProjectWorker) processQueue(ctx context.Context) { }); err != nil { w.log.Error("Failed to save execution metrics", slog.Any("error", err)) } + + // GH-2429: emit per-execution usage events (task + token + compute) so the + // `usage_events` table reflects real activity. UserID is single-tenant for + // now (empty); when multi-user lands, plumb the real ID through Execution. + if err := w.store.RecordTaskUsage( + exec.ID, + exec.UserID, + exec.ProjectPath, + duration.Milliseconds(), + result.TokensInput, + result.TokensOutput, + ); err != nil { + w.log.Error("Failed to record usage event", slog.Any("error", err)) + } } w.currentTaskID.Store("") diff --git a/internal/memory/store.go b/internal/memory/store.go index 0293ce51..e1729135 100644 --- a/internal/memory/store.go +++ b/internal/memory/store.go @@ -360,6 +360,10 @@ type Execution struct { ID string TaskID string ProjectPath string + // UserID identifies the user/tenant that owns this execution. + // Empty in single-tenant deployments; populated when multi-user mode is enabled. + // Used as the pivot for `usage_events` aggregation (GH-2429). + UserID string Status string Output string Error string