mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
* refactor(runtime): derive runtime usage from task_usage only
The daemon used to scan each runtime's local CLI log directory every 5
minutes (Claude Code, Codex, OpenCode, OpenClaw, Hermes) and post daily
aggregates to /api/daemon/runtimes/{id}/usage. Those directories are
shared with the user's own local CLI sessions, so the user's personal
usage was being counted as Daemon-executed usage. Cursor and Gemini had
no scanner at all, so their runtime-level aggregates were always zero.
Switch GetRuntimeUsage to aggregate task_usage (already scoped to
Daemon-executed tasks) via agent_task_queue.runtime_id. Single source of
truth; Cursor/Gemini/Copilot get runtime usage for free; no reliance on
external CLI log formats.
Removes:
- server/internal/daemon/usage/ (all scanners)
- Daemon.usageScanLoop + providerToRuntimeMap
- Client.ReportUsage
- ReportRuntimeUsage handler + POST /api/daemon/runtimes/{id}/usage
- UpsertRuntimeUsage / GetRuntimeUsageSummary queries
- runtime_usage table (migration 046)
Refs: MUL-786
* fix(runtime): bucket daily usage by task_usage.created_at, not enqueue time
ListRuntimeUsage was aggregating by DATE(atq.created_at) and filtering
on atq.created_at. agent_task_queue.created_at is the enqueue timestamp,
which drifts from actual token-production time: a task queued at 23:58
and executed at 00:05 was attributed to yesterday; a task sitting in
the queue overnight was counted on the queue day.
The ?days=N cutoff also became a rolling window (now() - N) instead of
a calendar-day boundary, silently clipping the morning of the earliest
day returned.
Switch bucket + filter to task_usage.created_at (~= task completion /
usage-report time) and snap the since cutoff to start-of-day via
DATE_TRUNC.
Add a regression test covering both scenarios: cross-midnight task
attributes to the day tokens were reported, and the earliest day's
pre-cutoff rows are still included.
118 lines
4.3 KiB
Go
118 lines
4.3 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestGetRuntimeUsage_BucketsByUsageTime ensures a task that was enqueued on
|
|
// one calendar day but whose tokens were reported the next day (e.g. execution
|
|
// crossed midnight, or the task sat in the queue) is attributed to the day
|
|
// tokens were actually produced, not the enqueue day. It also verifies the
|
|
// ?days=N cutoff covers the full earliest calendar day, not just "now minus N
|
|
// days" which would clip the morning of that day.
|
|
func TestGetRuntimeUsage_BucketsByUsageTime(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
// Pick a runtime bound to the fixture workspace.
|
|
var runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT id FROM agent_runtime WHERE workspace_id = $1 LIMIT 1
|
|
`, testWorkspaceID).Scan(&runtimeID); err != nil {
|
|
t.Fatalf("fetch runtime: %v", err)
|
|
}
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1
|
|
`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("fetch agent: %v", err)
|
|
}
|
|
|
|
// Create an issue for the tasks to reference.
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, creator_id, creator_type)
|
|
VALUES ($1, 'runtime usage test', $2, 'member')
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
|
|
// enqueued yesterday 23:58 UTC, finished today 00:05 UTC — tokens belong to today.
|
|
now := time.Now().UTC()
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
yesterdayLate := today.Add(-2 * time.Minute)
|
|
todayEarly := today.Add(5 * time.Minute)
|
|
// Task that ran entirely yesterday around 05:00 — used to verify the
|
|
// ?days cutoff isn't clipping yesterday's morning.
|
|
yesterdayMorning := today.Add(-19 * time.Hour)
|
|
|
|
insertTaskWithUsage := func(enqueueAt, usageAt time.Time, inputTokens int64) string {
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, issue_id, runtime_id, status, created_at)
|
|
VALUES ($1, $2, $3, 'completed', $4)
|
|
RETURNING id
|
|
`, agentID, issueID, runtimeID, enqueueAt).Scan(&taskID); err != nil {
|
|
t.Fatalf("insert task: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO task_usage (task_id, provider, model, input_tokens, output_tokens, created_at)
|
|
VALUES ($1, 'claude', 'claude-3-5-sonnet', $2, 0, $3)
|
|
`, taskID, inputTokens, usageAt); err != nil {
|
|
t.Fatalf("insert task_usage: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
|
})
|
|
return taskID
|
|
}
|
|
|
|
insertTaskWithUsage(yesterdayLate, todayEarly, 1000) // cross-midnight
|
|
insertTaskWithUsage(yesterdayMorning, yesterdayMorning, 2000) // full-day yesterday
|
|
|
|
// Call the handler with ?days=1 at whatever "now" is. That should include
|
|
// both today and yesterday in full.
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/runtimes/"+runtimeID+"/usage?days=1", nil)
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
testHandler.GetRuntimeUsage(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetRuntimeUsage: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp []RuntimeUsageResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
|
|
byDate := make(map[string]int64)
|
|
for _, r := range resp {
|
|
byDate[r.Date] += r.InputTokens
|
|
}
|
|
|
|
todayKey := today.Format("2006-01-02")
|
|
yesterdayKey := today.Add(-24 * time.Hour).Format("2006-01-02")
|
|
|
|
// Cross-midnight task must attribute to today (tu.created_at), not yesterday
|
|
// (atq.created_at). Before the fix this was 0 on today / 1000 on yesterday.
|
|
if byDate[todayKey] != 1000 {
|
|
t.Errorf("cross-midnight task: today bucket expected 1000 input tokens, got %d (full map: %v)", byDate[todayKey], byDate)
|
|
}
|
|
// Yesterday's morning task must still be included — this is what breaks
|
|
// when ?days=N is interpreted as a rolling window instead of calendar days.
|
|
if byDate[yesterdayKey] != 2000 {
|
|
t.Errorf("yesterday morning task: yesterday bucket expected 2000 input tokens, got %d (full map: %v)", byDate[yesterdayKey], byDate)
|
|
}
|
|
}
|