- {agent?.name ?? row.agentId}
+
+
+ {agent?.name ?? row.agentId}
+
+ {coverageText ? (
+
+ {coverageText}
+
+ ) : null}
>
)}
@@ -245,12 +295,12 @@ export function Leaderboard({
- {formatTokens(row.tokens)}
+ {tokenText}
- ${row.cost.toFixed(2)}
+ {costText}
{
expect(merged).toHaveLength(1);
expect(merged[0]!.taskCount).toBe(1);
expect(merged[0]!.seconds).toBe(600);
+ expect(merged[0]!.unreportedTaskCount).toBe(0);
+ expect(merged[0]!.hasReportedUsage).toBe(true);
+ expect(merged[0]!.hasUsageTotals).toBe(true);
});
it("falls back to token count when no run-time row exists (in-flight task)", () => {
@@ -214,15 +217,90 @@ describe("mergeAgentDashboardRows", () => {
it("includes agents that have run-time but no tokens", () => {
// Task errored before reporting any usage — run-time row exists but
// there's no corresponding token row. Agent must still appear on the
- // list with zeroed-out token columns.
+ // list with unavailable token columns.
const merged = mergeAgentDashboardRows(
[],
- [{ agent_id: "agent-c", total_seconds: 30, task_count: 1, failed_count: 1, cancelled_count: 0 }],
+ [
+ {
+ agent_id: "agent-c",
+ total_seconds: 30,
+ task_count: 1,
+ metered_task_count: 0,
+ failed_count: 1,
+ cancelled_count: 0,
+ },
+ ],
);
expect(merged).toHaveLength(1);
expect(merged[0]!.tokens).toBe(0);
expect(merged[0]!.cost).toBe(0);
expect(merged[0]!.taskCount).toBe(1);
+ expect(merged[0]!.unreportedTaskCount).toBe(1);
+ expect(merged[0]!.hasReportedUsage).toBe(false);
+ expect(merged[0]!.hasUsageTotals).toBe(false);
+ });
+
+ it("does not invent unreported runs for an old server", () => {
+ const merged = mergeAgentDashboardRows(
+ [],
+ [
+ {
+ agent_id: "agent-old-server",
+ total_seconds: 30,
+ task_count: 1,
+ failed_count: 1,
+ cancelled_count: 0,
+ },
+ ],
+ );
+ expect(merged[0]).toMatchObject({
+ unreportedTaskCount: 0,
+ hasReportedUsage: false,
+ hasUsageTotals: false,
+ });
+ });
+
+ it("separates real-time usage coverage from delayed aggregate totals", () => {
+ const merged = mergeAgentDashboardRows(
+ [],
+ [
+ {
+ agent_id: "agent-reported",
+ total_seconds: 60,
+ task_count: 2,
+ metered_task_count: 2,
+ failed_count: 0,
+ cancelled_count: 0,
+ },
+ ],
+ );
+ expect(merged[0]).toMatchObject({
+ unreportedTaskCount: 0,
+ hasReportedUsage: true,
+ hasUsageTotals: false,
+ });
+ });
+
+ it("keeps the exact partial-coverage count from a current server", () => {
+ const merged = mergeAgentDashboardRows(
+ [{ agentId: "agent-d", tokens: 100, cost: 0.5, taskCount: 1 }],
+ [
+ {
+ agent_id: "agent-d",
+ total_seconds: 90,
+ task_count: 3,
+ metered_task_count: 1,
+ failed_count: 0,
+ cancelled_count: 0,
+ },
+ ],
+ );
+ expect(merged[0]).toMatchObject({
+ taskCount: 3,
+ unreportedTaskCount: 2,
+ hasReportedUsage: true,
+ hasUsageTotals: true,
+ });
});
it("sorts by cost desc with run-time as a tiebreaker", () => {
@@ -241,13 +319,25 @@ describe("mergeAgentDashboardRows", () => {
});
describe("bucketUnknownAgentRows", () => {
- const live = { agentId: "live", tokens: 100, cost: 1, seconds: 10, taskCount: 1 };
+ const live = {
+ agentId: "live",
+ tokens: 100,
+ cost: 1,
+ seconds: 10,
+ taskCount: 1,
+ unreportedTaskCount: 0,
+ hasReportedUsage: true,
+ hasUsageTotals: true,
+ };
const archived = {
agentId: "archived",
tokens: 80,
cost: 0.8,
seconds: 8,
taskCount: 2,
+ unreportedTaskCount: 0,
+ hasReportedUsage: true,
+ hasUsageTotals: true,
};
const deletedA = {
agentId: "deleted-a",
@@ -255,6 +345,9 @@ describe("bucketUnknownAgentRows", () => {
cost: 0.5,
seconds: 5,
taskCount: 1,
+ unreportedTaskCount: 0,
+ hasReportedUsage: true,
+ hasUsageTotals: true,
};
const deletedB = {
agentId: "deleted-b",
@@ -262,6 +355,9 @@ describe("bucketUnknownAgentRows", () => {
cost: 0.25,
seconds: 3,
taskCount: 4,
+ unreportedTaskCount: 0,
+ hasReportedUsage: true,
+ hasUsageTotals: true,
};
it("folds every hard-deleted agent into one aggregated bucket row", () => {
@@ -279,6 +375,8 @@ describe("bucketUnknownAgentRows", () => {
// `agent`, so deleted agents contribute nothing to those columns.
expect(bucket.seconds).toBe(0);
expect(bucket.taskCount).toBe(0);
+ expect(bucket.hasReportedUsage).toBe(true);
+ expect(bucket.hasUsageTotals).toBe(true);
});
it("keeps the bucket total reconciled with the top-line spend", () => {
@@ -307,6 +405,34 @@ describe("bucketUnknownAgentRows", () => {
]);
});
+ it("keeps incomplete coverage through a cross-query deletion race", () => {
+ const deletedBeforeAgentListResolved = {
+ agentId: "just-deleted",
+ tokens: 0,
+ cost: 0,
+ seconds: 90,
+ taskCount: 3,
+ unreportedTaskCount: 2,
+ hasReportedUsage: true,
+ hasUsageTotals: false,
+ };
+ const out = bucketUnknownAgentRows(
+ [deletedA, deletedBeforeAgentListResolved],
+ new Set(),
+ );
+ expect(out).toHaveLength(1);
+ expect(out[0]).toMatchObject({
+ agentId: DELETED_AGENTS_ROW_ID,
+ unreportedTaskCount: 2,
+ hasReportedUsage: true,
+ hasUsageTotals: true,
+ });
+ // The existing deleted-agent contract still hides Time / Tasks because
+ // the normal run-time query excludes agents deleted before it executes.
+ expect(out[0]!.seconds).toBe(0);
+ expect(out[0]!.taskCount).toBe(0);
+ });
+
it("adds no bucket row when every agent is known", () => {
const out = bucketUnknownAgentRows([live, archived], new Set(["live", "archived"]));
expect(out.map((r) => r.agentId)).toEqual(["live", "archived"]);
@@ -328,6 +454,9 @@ describe("bucketUnknownAgentRows", () => {
cost: 0.7,
seconds: 42,
taskCount: 3,
+ unreportedTaskCount: 1,
+ hasReportedUsage: true,
+ hasUsageTotals: true,
};
const out = bucketUnknownAgentRows(
[live, restricted, deletedA],
diff --git a/packages/views/dashboard/utils.ts b/packages/views/dashboard/utils.ts
index 9915aa614..fd209593a 100644
--- a/packages/views/dashboard/utils.ts
+++ b/packages/views/dashboard/utils.ts
@@ -205,6 +205,12 @@ export interface AgentDashboardRow {
cost: number;
seconds: number;
taskCount: number;
+ unreportedTaskCount: number;
+ hasReportedUsage: boolean;
+ // Token/cost totals come from the asynchronous hourly rollup. Keep their
+ // availability separate from real-time task_usage coverage so rollup lag
+ // never turns a reported run into either a fake zero or an unreported run.
+ hasUsageTotals: boolean;
}
// Merge per-agent token totals with per-agent run-time totals into one
@@ -226,25 +232,44 @@ export function mergeAgentDashboardRows(
const merged = new Map();
for (const r of tokenRows) {
const rt = runTimeByAgent.get(r.agentId);
+ const taskCount = rt ? rt.task_count : r.taskCount;
+ // An older server omits metered_task_count. A token row proves that some
+ // usage exists but cannot tell which of several runs produced it, so keep
+ // the old all-reported presentation until the exact coverage field is
+ // available. This avoids a new client inventing partial coverage while
+ // connected to an old backend.
+ const meteredTaskCount = rt?.metered_task_count ?? taskCount;
merged.set(r.agentId, {
agentId: r.agentId,
tokens: r.tokens,
cost: r.cost,
seconds: rt?.total_seconds ?? 0,
- taskCount: rt ? rt.task_count : r.taskCount,
+ taskCount,
+ unreportedTaskCount: Math.max(0, taskCount - meteredTaskCount),
+ hasReportedUsage: true,
+ hasUsageTotals: true,
});
}
// Agents with run-time rows but zero tokens still belong on the list
- // (a task that errored before producing usage). Their token columns
- // stay at 0.
+ // (a task that errored before producing usage). Their numeric token totals
+ // stay at 0 for sorting, while the leaderboard renders them as unavailable.
for (const r of runTimeRows) {
if (merged.has(r.agent_id)) continue;
+ // Old servers omit the coverage field. The missing token aggregate may
+ // merely be lagging, so do not reinterpret every run as unreported.
+ const coverageKnown = r.metered_task_count !== undefined;
+ const meteredTaskCount = r.metered_task_count ?? 0;
merged.set(r.agent_id, {
agentId: r.agent_id,
tokens: 0,
cost: 0,
seconds: r.total_seconds,
taskCount: r.task_count,
+ unreportedTaskCount: coverageKnown
+ ? Math.max(0, r.task_count - meteredTaskCount)
+ : 0,
+ hasReportedUsage: coverageKnown && meteredTaskCount > 0,
+ hasUsageTotals: false,
});
}
return Array.from(merged.values()).toSorted((a, b) => {
@@ -280,10 +305,12 @@ export const RESTRICTED_AGENTS_ROW_ID = "__restricted_agents__";
// (those totals aggregate `task_usage_hourly` without joining `agent`), so the
// per-agent breakdown no longer reconciled with the totals (MUL-3776, #4640).
// Aggregating instead of dropping keeps `sum(visible rows) == KPI total` while
-// still never exposing a UUID. The bucket carries tokens + cost only; seconds
-// and taskCount stay 0 because the run-time rollups inner-join `agent`, so
-// deleted agents already contribute nothing to the Time/Tasks KPIs — the
-// component renders those two columns as "—" for this row.
+// still never exposing a UUID. The bucket carries tokens + cost and their
+// coverage metadata only; seconds and taskCount stay 0 because the run-time
+// rollups inner-join `agent`, so deleted agents already contribute nothing to
+// the Time/Tasks KPIs — the component renders those two columns as "—" for
+// this row. Preserving coverage also handles a cross-query deletion race where
+// a run-time row was read just before the agent disappeared.
//
// `knownAgentIds` is `null` while the agent list is still loading; callers
// pass `null` in that case so the rows pass through untouched instead of the
@@ -305,6 +332,9 @@ export function bucketUnknownAgentRows(
cost: 0,
seconds: 0,
taskCount: 0,
+ unreportedTaskCount: 0,
+ hasReportedUsage: false,
+ hasUsageTotals: false,
};
let hasDeleted = false;
for (const r of rows) {
@@ -315,6 +345,9 @@ export function bucketUnknownAgentRows(
hasDeleted = true;
bucket.tokens += r.tokens;
bucket.cost += r.cost;
+ bucket.unreportedTaskCount += r.unreportedTaskCount;
+ bucket.hasReportedUsage ||= r.hasReportedUsage;
+ bucket.hasUsageTotals ||= r.hasUsageTotals;
}
return hasDeleted ? [...known, bucket] : known;
}
diff --git a/packages/views/locales/en/usage.json b/packages/views/locales/en/usage.json
index 8817bcbef..6ecaba149 100644
--- a/packages/views/locales/en/usage.json
+++ b/packages/views/locales/en/usage.json
@@ -94,6 +94,11 @@
"header_cost": "Cost",
"header_time": "Time",
"header_tasks": "Runs",
+ "usage_unreported_one": "{{count}} run did not report usage",
+ "usage_unreported_other": "{{count}} runs did not report usage",
+ "usage_unreported_pending_one": "{{count}} run did not report usage · totals are still being processed",
+ "usage_unreported_pending_other": "{{count}} runs did not report usage · totals are still being processed",
+ "usage_totals_pending": "Usage totals are still being processed",
"show_all": "Show all",
"show_less": "Show top {{count}}",
"no_data": "No agent activity in this window."
diff --git a/packages/views/locales/ja/usage.json b/packages/views/locales/ja/usage.json
index 7e692d75b..f4ed0f0b5 100644
--- a/packages/views/locales/ja/usage.json
+++ b/packages/views/locales/ja/usage.json
@@ -94,6 +94,9 @@
"header_cost": "コスト",
"header_time": "時間",
"header_tasks": "実行",
+ "usage_unreported_other": "{{count}} 件の実行で使用量が報告されていません",
+ "usage_unreported_pending_other": "{{count}} 件の実行で使用量が報告されていません · 使用量を集計しています",
+ "usage_totals_pending": "使用量を集計しています",
"show_all": "すべて表示",
"show_less": "上位 {{count}} 件のみ",
"no_data": "この期間にエージェントの活動はありません。"
diff --git a/packages/views/locales/ko/usage.json b/packages/views/locales/ko/usage.json
index 0a0d32933..204b46f9f 100644
--- a/packages/views/locales/ko/usage.json
+++ b/packages/views/locales/ko/usage.json
@@ -94,6 +94,9 @@
"header_cost": "비용",
"header_time": "시간",
"header_tasks": "실행",
+ "usage_unreported_other": "{{count}}개 실행에서 사용량이 보고되지 않음",
+ "usage_unreported_pending_other": "{{count}}개 실행에서 사용량이 보고되지 않음 · 사용량을 집계하는 중",
+ "usage_totals_pending": "사용량을 집계하는 중",
"show_all": "전체 보기",
"show_less": "상위 {{count}}개만",
"no_data": "이 기간에는 에이전트 활동이 없습니다."
diff --git a/packages/views/locales/zh-Hans/usage.json b/packages/views/locales/zh-Hans/usage.json
index e4562b65d..5d23749e2 100644
--- a/packages/views/locales/zh-Hans/usage.json
+++ b/packages/views/locales/zh-Hans/usage.json
@@ -94,6 +94,9 @@
"header_cost": "费用",
"header_time": "耗时",
"header_tasks": "运行数",
+ "usage_unreported_other": "{{count}} 次运行未上报用量",
+ "usage_unreported_pending_other": "{{count}} 次运行未上报用量 · 用量汇总仍在处理中",
+ "usage_totals_pending": "用量汇总仍在处理中",
"show_all": "展开全部",
"show_less": "只看前 {{count}} 个",
"no_data": "所选时间范围内暂无智能体活动。"
diff --git a/server/cmd/multica/cmd_issue.go b/server/cmd/multica/cmd_issue.go
index 1d2eb2b62..7938c304c 100644
--- a/server/cmd/multica/cmd_issue.go
+++ b/server/cmd/multica/cmd_issue.go
@@ -346,8 +346,11 @@ var issueRunMessagesCmd = &cobra.Command{
var issueUsageCmd = &cobra.Command{
Use: "usage ",
Short: "Show aggregated token usage for an issue",
- Args: exactArgs(1),
- RunE: runIssueUsage,
+ Long: "Show aggregated token usage for an issue.\n\n" +
+ "In table output, RUNS counts terminal runs. Token totals prefixed with >= " +
+ "are lower bounds because one or more terminal runs did not report usage.",
+ Args: exactArgs(1),
+ RunE: runIssueUsage,
}
var issueRerunCmd = &cobra.Command{
@@ -2445,20 +2448,53 @@ func runIssueUsage(cmd *cobra.Command, args []string) error {
return cli.PrintJSON(os.Stdout, result)
}
- // JSON numbers decode to float64; formatMetadataValue renders them as clean
- // integers (no scientific notation for large cache-token counts).
- headers := []string{"INPUT_TOKENS", "OUTPUT_TOKENS", "CACHE_READ", "CACHE_WRITE", "RUNS"}
+ terminal, hasTerminal := result["terminal_task_count"]
+ metered, hasMetered := result["metered_task_count"]
+ unreported, hasUnreported := result["unreported_task_count"]
+ if !hasTerminal {
+ terminal = result["task_count"]
+ }
+ if !hasMetered {
+ metered = result["task_count"]
+ }
+ if !hasUnreported {
+ unreported = "—"
+ }
+ usageRows := result["task_count"]
+
+ // JSON numbers decode to float64; formatIssueUsageTokens and
+ // formatMetadataValue render them as clean integers (no scientific
+ // notation for large cache-token counts).
+ headers := []string{"INPUT_TOKENS", "OUTPUT_TOKENS", "CACHE_READ", "CACHE_WRITE", "RUNS", "METERED_RUNS", "UNREPORTED"}
rows := [][]string{{
- formatMetadataValue(result["total_input_tokens"]),
- formatMetadataValue(result["total_output_tokens"]),
- formatMetadataValue(result["total_cache_read_tokens"]),
- formatMetadataValue(result["total_cache_write_tokens"]),
- formatMetadataValue(result["task_count"]),
+ formatIssueUsageTokens(result["total_input_tokens"], terminal, metered, usageRows, hasTerminal && hasMetered),
+ formatIssueUsageTokens(result["total_output_tokens"], terminal, metered, usageRows, hasTerminal && hasMetered),
+ formatIssueUsageTokens(result["total_cache_read_tokens"], terminal, metered, usageRows, hasTerminal && hasMetered),
+ formatIssueUsageTokens(result["total_cache_write_tokens"], terminal, metered, usageRows, hasTerminal && hasMetered),
+ formatMetadataValue(terminal),
+ formatMetadataValue(metered),
+ formatMetadataValue(unreported),
}}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
+func formatIssueUsageTokens(value, terminal, metered, usageRows any, coverageKnown bool) string {
+ if !coverageKnown {
+ return formatMetadataValue(value)
+ }
+ terminalCount, terminalOK := terminal.(float64)
+ meteredCount, meteredOK := metered.(float64)
+ if !terminalOK || !meteredOK || terminalCount <= meteredCount {
+ return formatMetadataValue(value)
+ }
+ usageRowCount, usageRowsOK := usageRows.(float64)
+ if meteredCount == 0 && usageRowsOK && usageRowCount == 0 {
+ return "—"
+ }
+ return ">=" + formatMetadataValue(value)
+}
+
func runIssueRunMessages(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
diff --git a/server/cmd/multica/cmd_issue_test.go b/server/cmd/multica/cmd_issue_test.go
index 447c6fe88..460d6ba99 100644
--- a/server/cmd/multica/cmd_issue_test.go
+++ b/server/cmd/multica/cmd_issue_test.go
@@ -658,6 +658,9 @@ func TestRunIssueUsageReturnsTokenSummaryAsJSON(t *testing.T) {
"total_cache_read_tokens": float64(537800),
"total_cache_write_tokens": float64(42400),
"task_count": float64(1),
+ "terminal_task_count": float64(2),
+ "metered_task_count": float64(1),
+ "unreported_task_count": float64(1),
})
default:
http.NotFound(w, r)
@@ -667,7 +670,7 @@ func TestRunIssueUsageReturnsTokenSummaryAsJSON(t *testing.T) {
t.Setenv("MULTICA_SERVER_URL", srv.URL)
t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
- t.Setenv("MULTICA_TOKEN", "test-token")
+ t.Setenv("MULTICA_TOKEN", "mat_test-token")
cmd := newIssueUsageTestCmd()
_ = cmd.Flags().Set("output", "json")
@@ -691,11 +694,123 @@ func TestRunIssueUsageReturnsTokenSummaryAsJSON(t *testing.T) {
}
if payload["total_input_tokens"] != float64(3800) || payload["total_output_tokens"] != float64(11700) ||
payload["total_cache_read_tokens"] != float64(537800) || payload["total_cache_write_tokens"] != float64(42400) ||
- payload["task_count"] != float64(1) {
+ payload["task_count"] != float64(1) || payload["terminal_task_count"] != float64(2) ||
+ payload["metered_task_count"] != float64(1) || payload["unreported_task_count"] != float64(1) {
t.Fatalf("unexpected usage payload: %#v", payload)
}
}
+func TestFormatIssueUsageTokensDistinguishesUnreportedRuns(t *testing.T) {
+ tests := []struct {
+ name string
+ value any
+ terminal any
+ metered any
+ usageRows any
+ coverageKnown bool
+ want string
+ }{
+ {"complete", float64(3800), float64(1), float64(1), float64(1), true, "3800"},
+ {"partially reported", float64(3800), float64(2), float64(1), float64(1), true, ">=3800"},
+ {"fully unreported", float64(0), float64(4), float64(0), float64(0), true, "—"},
+ {"nonterminal usage with unreported terminal run", float64(40000), float64(1), float64(0), float64(1), true, ">=40000"},
+ {"only nonterminal usage", float64(40000), float64(0), float64(0), float64(1), true, "40000"},
+ {"unknown usage row count preserves known total", float64(40000), float64(1), float64(0), nil, true, ">=40000"},
+ {"old server", float64(0), nil, float64(0), float64(0), false, "0"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := formatIssueUsageTokens(tt.value, tt.terminal, tt.metered, tt.usageRows, tt.coverageKnown); got != tt.want {
+ t.Fatalf("formatIssueUsageTokens() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestRunIssueUsageTableKeepsNonterminalUsage(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/issues/MUL-2818":
+ json.NewEncoder(w).Encode(map[string]any{
+ "id": "issue-uuid",
+ "identifier": "MUL-2818",
+ "title": "CLI usage lookup",
+ })
+ case "/api/issues/issue-uuid/usage":
+ json.NewEncoder(w).Encode(map[string]any{
+ "total_input_tokens": 40000,
+ "total_output_tokens": 0,
+ "total_cache_read_tokens": 0,
+ "total_cache_write_tokens": 0,
+ "task_count": 1,
+ "terminal_task_count": 1,
+ "metered_task_count": 0,
+ "unreported_task_count": 1,
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+
+ t.Setenv("MULTICA_SERVER_URL", srv.URL)
+ t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
+ t.Setenv("MULTICA_TOKEN", "mat_test-token")
+
+ out, err := captureStdout(t, func() error {
+ return runIssueUsage(newIssueUsageTestCmd(), []string{"MUL-2818"})
+ })
+ if err != nil {
+ t.Fatalf("runIssueUsage: %v", err)
+ }
+ if !strings.Contains(out, ">=40000") {
+ t.Fatalf("table output hides nonterminal usage:\n%s", out)
+ }
+}
+
+func TestRunIssueUsageTableSeparatesRunsFromMeteredRuns(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/issues/MUL-2818":
+ json.NewEncoder(w).Encode(map[string]any{
+ "id": "issue-uuid",
+ "identifier": "MUL-2818",
+ "title": "CLI usage lookup",
+ })
+ case "/api/issues/issue-uuid/usage":
+ json.NewEncoder(w).Encode(map[string]any{
+ "total_input_tokens": 0,
+ "total_output_tokens": 0,
+ "total_cache_read_tokens": 0,
+ "total_cache_write_tokens": 0,
+ "task_count": 0,
+ "terminal_task_count": 4,
+ "metered_task_count": 0,
+ "unreported_task_count": 4,
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+
+ t.Setenv("MULTICA_SERVER_URL", srv.URL)
+ t.Setenv("MULTICA_WORKSPACE_ID", "ws-1")
+ t.Setenv("MULTICA_TOKEN", "mat_test-token")
+
+ out, err := captureStdout(t, func() error {
+ return runIssueUsage(newIssueUsageTestCmd(), []string{"MUL-2818"})
+ })
+ if err != nil {
+ t.Fatalf("runIssueUsage: %v", err)
+ }
+ for _, want := range []string{"METERED_RUNS", "UNREPORTED", "—", "4"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("table output missing %q:\n%s", want, out)
+ }
+ }
+}
+
func TestRunIssuePullRequestsTableIncludesCoreFields(t *testing.T) {
prs := []map[string]any{{
"url": "https://github.com/multica-ai/multica/pull/42",
diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go
index 07d34c756..d8bac4ad9 100644
--- a/server/internal/handler/daemon.go
+++ b/server/internal/handler/daemon.go
@@ -5413,7 +5413,13 @@ func (h *Handler) GetIssueUsage(w http.ResponseWriter, r *http.Request) {
"uncosted_output_tokens": row.UncostedOutputTokens,
"uncosted_cache_read_tokens": row.UncostedCacheReadTokens,
"uncosted_cache_write_tokens": row.UncostedCacheWriteTokens,
- "task_count": row.TaskCount,
+ // task_count is the legacy count of tasks with usage. Keep it stable for
+ // installed clients while the explicit coverage fields distinguish runs
+ // from metered runs.
+ "task_count": row.TaskCount,
+ "terminal_task_count": row.TerminalTaskCount,
+ "metered_task_count": row.MeteredTaskCount,
+ "unreported_task_count": row.UnreportedTaskCount,
})
}
diff --git a/server/internal/handler/daemon_test.go b/server/internal/handler/daemon_test.go
index 97616bd51..83aaf8309 100644
--- a/server/internal/handler/daemon_test.go
+++ b/server/internal/handler/daemon_test.go
@@ -1475,6 +1475,64 @@ func TestGetIssueUsage_CrossWorkspace_Returns404(t *testing.T) {
testutil.Call(t, testHandler.GetIssueUsage, req).Want(http.StatusNotFound)
}
+func TestGetIssueUsageReportsUsageCoverage(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ var agentID, runtimeID string
+ dbfx.QueryRow(t, `
+ SELECT id, runtime_id FROM agent
+ WHERE workspace_id = $1 AND runtime_id IS NOT NULL
+ LIMIT 1
+ `, testWorkspaceID).Scan(&agentID, &runtimeID)
+ issueID := dbfx.Issue(t, "issue usage coverage")
+ finished := testutil.Cols{
+ "issue_id": issueID,
+ "runtime_id": runtimeID,
+ "started_at": testutil.Raw("now() - interval '2 minutes'"),
+ "completed_at": testutil.Raw("now() - interval '1 minute'"),
+ }
+ meteredTaskID := dbfx.Task(t, agentID, finished, testutil.Cols{"status": "completed"})
+ dbfx.Task(t, agentID, finished, testutil.Cols{"status": "failed"})
+ // A cancelled task that never started and a queued task are not runs, so
+ // neither belongs in terminal_task_count or unreported_task_count.
+ dbfx.Task(t, agentID, testutil.Cols{
+ "issue_id": issueID,
+ "runtime_id": runtimeID,
+ "status": "cancelled",
+ "completed_at": testutil.Raw("now() - interval '1 minute'"),
+ })
+ dbfx.Task(t, agentID, testutil.Cols{"issue_id": issueID, "runtime_id": runtimeID})
+ dbfx.Insert(t, "task_usage", testutil.Cols{
+ "task_id": meteredTaskID,
+ "provider": "qoderclicn",
+ "model": "bailian/tp/qwen3.8-max",
+ "input_tokens": 120,
+ "output_tokens": 30,
+ })
+
+ req := newRequest("GET", "/api/issues/"+issueID+"/usage", nil)
+ req = withURLParam(req, "id", issueID)
+ var got struct {
+ TotalInputTokens int64 `json:"total_input_tokens"`
+ TotalOutputTokens int64 `json:"total_output_tokens"`
+ TaskCount int32 `json:"task_count"`
+ TerminalTaskCount int32 `json:"terminal_task_count"`
+ MeteredTaskCount int32 `json:"metered_task_count"`
+ UnreportedTaskCount int32 `json:"unreported_task_count"`
+ }
+ testutil.Call(t, testHandler.GetIssueUsage, req).Want(http.StatusOK).JSON(&got)
+
+ if got.TotalInputTokens != 120 || got.TotalOutputTokens != 30 {
+ t.Errorf("token totals = %d/%d, want 120/30", got.TotalInputTokens, got.TotalOutputTokens)
+ }
+ if got.TaskCount != 1 || got.TerminalTaskCount != 2 || got.MeteredTaskCount != 1 || got.UnreportedTaskCount != 1 {
+ t.Errorf("coverage = legacy:%d terminal:%d metered:%d unreported:%d, want 1/2/1/1",
+ got.TaskCount, got.TerminalTaskCount, got.MeteredTaskCount, got.UnreportedTaskCount)
+ }
+}
+
func TestGetDaemonWorkspaceRepos_WithDaemonToken(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
diff --git a/server/internal/handler/dashboard.go b/server/internal/handler/dashboard.go
index 1b2c41de3..1d6bffce3 100644
--- a/server/internal/handler/dashboard.go
+++ b/server/internal/handler/dashboard.go
@@ -368,11 +368,12 @@ func (h *Handler) listDashboardUsageByAgent(
// FailedCount and CancelledCount are disjoint subsets of TaskCount; the
// client derives the succeeded count as the remainder.
type DashboardAgentRunTimeResponse struct {
- AgentID string `json:"agent_id"`
- TotalSeconds int64 `json:"total_seconds"`
- TaskCount int32 `json:"task_count"`
- FailedCount int32 `json:"failed_count"`
- CancelledCount int32 `json:"cancelled_count"`
+ AgentID string `json:"agent_id"`
+ TotalSeconds int64 `json:"total_seconds"`
+ TaskCount int32 `json:"task_count"`
+ MeteredTaskCount int32 `json:"metered_task_count"`
+ FailedCount int32 `json:"failed_count"`
+ CancelledCount int32 `json:"cancelled_count"`
}
// GetDashboardAgentRunTime returns per-agent total task run time (seconds)
@@ -417,11 +418,12 @@ func (h *Handler) GetDashboardAgentRunTime(w http.ResponseWriter, r *http.Reques
resp := make([]DashboardAgentRunTimeResponse, len(rows))
for i, row := range rows {
resp[i] = DashboardAgentRunTimeResponse{
- AgentID: uuidToString(row.AgentID),
- TotalSeconds: row.TotalSeconds,
- TaskCount: row.TaskCount,
- FailedCount: row.FailedCount,
- CancelledCount: row.CancelledCount,
+ AgentID: uuidToString(row.AgentID),
+ TotalSeconds: row.TotalSeconds,
+ TaskCount: row.TaskCount,
+ MeteredTaskCount: row.MeteredTaskCount,
+ FailedCount: row.FailedCount,
+ CancelledCount: row.CancelledCount,
}
}
writeJSON(w, http.StatusOK, foldRestrictedAgentRunTime(resp, restricted))
@@ -444,6 +446,7 @@ func foldRestrictedAgentRunTime(
func(dst, src DashboardAgentRunTimeResponse) DashboardAgentRunTimeResponse {
dst.TotalSeconds += src.TotalSeconds
dst.TaskCount += src.TaskCount
+ dst.MeteredTaskCount += src.MeteredTaskCount
dst.FailedCount += src.FailedCount
dst.CancelledCount += src.CancelledCount
return dst
diff --git a/server/internal/handler/dashboard_agent_visibility_test.go b/server/internal/handler/dashboard_agent_visibility_test.go
index 404ecbf4d..b76c3524a 100644
--- a/server/internal/handler/dashboard_agent_visibility_test.go
+++ b/server/internal/handler/dashboard_agent_visibility_test.go
@@ -199,10 +199,11 @@ func TestDashboardPerAgentRollupsFoldRestrictedAgents(t *testing.T) {
// ---- agent-runtime -----------------------------------------------------
type runTimeRow struct {
- AgentID string `json:"agent_id"`
- TotalSeconds int64 `json:"total_seconds"`
- TaskCount int32 `json:"task_count"`
- FailedCount int32 `json:"failed_count"`
+ AgentID string `json:"agent_id"`
+ TotalSeconds int64 `json:"total_seconds"`
+ TaskCount int32 `json:"task_count"`
+ MeteredTaskCount int32 `json:"metered_task_count"`
+ FailedCount int32 `json:"failed_count"`
}
var ownerRunTime, memberRunTime []runTimeRow
const runTimePath = "/api/dashboard/agent-runtime?days=7"
@@ -215,19 +216,20 @@ func TestDashboardPerAgentRollupsFoldRestrictedAgents(t *testing.T) {
assertDashboardAgentPresence(t, "agent-runtime (member)", dashboardAgentIDSet(memberRunTime, runTimeID),
dashboardAgentPresence{privateAgent: false, publicAgent: true, sentinel: true}, privateAgentID, publicAgentID)
- sumRunTime := func(rows []runTimeRow) (secs int64, tasks, failed int32) {
+ sumRunTime := func(rows []runTimeRow) (secs int64, tasks, metered, failed int32) {
for _, r := range rows {
secs += r.TotalSeconds
tasks += r.TaskCount
+ metered += r.MeteredTaskCount
failed += r.FailedCount
}
return
}
- ownerSecs, ownerTasks, ownerFailed := sumRunTime(ownerRunTime)
- memberSecs, memberTasks, memberFailed := sumRunTime(memberRunTime)
- if ownerSecs != memberSecs || ownerTasks != memberTasks || ownerFailed != memberFailed {
- t.Errorf("agent-runtime: folding changed the totals — owner (%ds, %d tasks, %d failed), member (%ds, %d tasks, %d failed)",
- ownerSecs, ownerTasks, ownerFailed, memberSecs, memberTasks, memberFailed)
+ ownerSecs, ownerTasks, ownerMetered, ownerFailed := sumRunTime(ownerRunTime)
+ memberSecs, memberTasks, memberMetered, memberFailed := sumRunTime(memberRunTime)
+ if ownerSecs != memberSecs || ownerTasks != memberTasks || ownerMetered != memberMetered || ownerFailed != memberFailed {
+ t.Errorf("agent-runtime: folding changed the totals — owner (%ds, %d tasks, %d metered, %d failed), member (%ds, %d tasks, %d metered, %d failed)",
+ ownerSecs, ownerTasks, ownerMetered, ownerFailed, memberSecs, memberTasks, memberMetered, memberFailed)
}
// One bucket, not one row per hidden agent: how MANY private agents exist
// is itself part of what "private" hides.
diff --git a/server/internal/handler/dashboard_test.go b/server/internal/handler/dashboard_test.go
index 2df321b12..084715bdd 100644
--- a/server/internal/handler/dashboard_test.go
+++ b/server/internal/handler/dashboard_test.go
@@ -9,7 +9,9 @@ import (
"testing"
"time"
+ "github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/testutil"
+ db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// dashboardFixtureTZ is the zone the day-boundary fixtures in this file are
@@ -365,6 +367,60 @@ func TestDashboardEndpoints(t *testing.T) {
}
}
+// TestDashboardAgentRunTimeReportsMeteredTaskCoverage proves the runtime
+// rollup distinguishes terminal runs from the subset that reported usage.
+func TestDashboardAgentRunTimeReportsMeteredTaskCoverage(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ var agentID, runtimeID string
+ dbfx.QueryRow(t, `
+ SELECT id, runtime_id FROM agent
+ WHERE workspace_id = $1 AND runtime_id IS NOT NULL
+ LIMIT 1
+ `, testWorkspaceID).Scan(&agentID, &runtimeID)
+
+ readCoverage := func() (int32, int32) {
+ rows, err := testHandler.Queries.ListDashboardAgentRunTime(context.Background(), db.ListDashboardAgentRunTimeParams{
+ WorkspaceID: parseUUID(testWorkspaceID),
+ Since: pgtype.Timestamptz{Time: time.Now().Add(-24 * time.Hour), Valid: true},
+ })
+ if err != nil {
+ t.Fatalf("list dashboard agent runtime: %v", err)
+ }
+ for _, row := range rows {
+ if uuidToString(row.AgentID) == agentID {
+ return row.TaskCount, row.MeteredTaskCount
+ }
+ }
+ return 0, 0
+ }
+ baseTasks, baseMetered := readCoverage()
+
+ issueID := dbfx.Issue(t, "dashboard usage coverage")
+ finished := testutil.Cols{
+ "issue_id": issueID,
+ "runtime_id": runtimeID,
+ "started_at": testutil.Raw("now() - interval '2 minutes'"),
+ "completed_at": testutil.Raw("now() - interval '1 minute'"),
+ }
+ meteredTaskID := dbfx.Task(t, agentID, finished, testutil.Cols{"status": "completed"})
+ dbfx.Task(t, agentID, finished, testutil.Cols{"status": "failed"})
+ dbfx.Insert(t, "task_usage", testutil.Cols{
+ "task_id": meteredTaskID,
+ "provider": "qoderclicn",
+ "model": "bailian/tp/qwen3.8-max",
+ "input_tokens": 120,
+ "output_tokens": 30,
+ })
+
+ tasks, metered := readCoverage()
+ if tasks-baseTasks != 2 || metered-baseMetered != 1 {
+ t.Fatalf("coverage delta = terminal:%d metered:%d, want 2/1", tasks-baseTasks, metered-baseMetered)
+ }
+}
+
// TestDashboardUsageDailyBucketsByViewerTimezone proves the `?tz=` query
// param drives the calendar-day boundary: the same UTC instant lands under
// a different `date` for a UTC viewer vs an America/Los_Angeles viewer.
diff --git a/server/pkg/db/generated/task_usage.sql.go b/server/pkg/db/generated/task_usage.sql.go
index efabb69a5..900c341bf 100644
--- a/server/pkg/db/generated/task_usage.sql.go
+++ b/server/pkg/db/generated/task_usage.sql.go
@@ -12,20 +12,40 @@ import (
)
const getIssueUsageSummary = `-- name: GetIssueUsageSummary :one
+WITH usage AS (
+ SELECT
+ COALESCE(SUM(tu.input_tokens), 0)::bigint AS total_input_tokens,
+ COALESCE(SUM(tu.output_tokens), 0)::bigint AS total_output_tokens,
+ COALESCE(SUM(tu.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
+ COALESCE(SUM(tu.cache_write_tokens), 0)::bigint AS total_cache_write_tokens,
+ COALESCE(SUM(tu.cost_usd_ticks), 0)::bigint AS total_cost_usd_ticks,
+ COALESCE(SUM(tu.input_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_input_tokens,
+ COALESCE(SUM(tu.output_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_output_tokens,
+ COALESCE(SUM(tu.cache_read_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_read_tokens,
+ COALESCE(SUM(tu.cache_write_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_write_tokens,
+ COUNT(DISTINCT tu.task_id)::int AS task_count
+ FROM task_usage tu
+ JOIN agent_task_queue atq ON atq.id = tu.task_id
+ WHERE atq.issue_id = $1
+), terminal_runs AS (
+ SELECT
+ COUNT(*)::int AS terminal_task_count,
+ COUNT(*) FILTER (WHERE EXISTS (
+ SELECT 1 FROM task_usage tu WHERE tu.task_id = atq.id
+ ))::int AS metered_task_count
+ FROM agent_task_queue atq
+ WHERE atq.issue_id = $1
+ AND atq.status IN ('completed', 'failed', 'cancelled')
+ AND atq.started_at IS NOT NULL
+ AND atq.completed_at IS NOT NULL
+)
SELECT
- COALESCE(SUM(tu.input_tokens), 0)::bigint AS total_input_tokens,
- COALESCE(SUM(tu.output_tokens), 0)::bigint AS total_output_tokens,
- COALESCE(SUM(tu.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
- COALESCE(SUM(tu.cache_write_tokens), 0)::bigint AS total_cache_write_tokens,
- COALESCE(SUM(tu.cost_usd_ticks), 0)::bigint AS total_cost_usd_ticks,
- COALESCE(SUM(tu.input_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_input_tokens,
- COALESCE(SUM(tu.output_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_output_tokens,
- COALESCE(SUM(tu.cache_read_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_read_tokens,
- COALESCE(SUM(tu.cache_write_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_write_tokens,
- COUNT(DISTINCT tu.task_id)::int AS task_count
-FROM task_usage tu
-JOIN agent_task_queue atq ON atq.id = tu.task_id
-WHERE atq.issue_id = $1
+ usage.total_input_tokens, usage.total_output_tokens, usage.total_cache_read_tokens, usage.total_cache_write_tokens, usage.total_cost_usd_ticks, usage.uncosted_input_tokens, usage.uncosted_output_tokens, usage.uncosted_cache_read_tokens, usage.uncosted_cache_write_tokens, usage.task_count,
+ terminal_runs.terminal_task_count,
+ terminal_runs.metered_task_count,
+ (terminal_runs.terminal_task_count - terminal_runs.metered_task_count)::int AS unreported_task_count
+FROM usage
+CROSS JOIN terminal_runs
`
type GetIssueUsageSummaryRow struct {
@@ -39,8 +59,15 @@ type GetIssueUsageSummaryRow struct {
UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"`
UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"`
TaskCount int32 `json:"task_count"`
+ TerminalTaskCount int32 `json:"terminal_task_count"`
+ MeteredTaskCount int32 `json:"metered_task_count"`
+ UnreportedTaskCount int32 `json:"unreported_task_count"`
}
+// Keep the legacy usage aggregates intact, then report coverage over finite
+// terminal runs separately. A task_usage row is the durable evidence that a
+// run reported usage even when every token counter is legitimately zero.
+// Both passes use the existing issue_id / task_id indexes (migrations 035/032).
func (q *Queries) GetIssueUsageSummary(ctx context.Context, issueID pgtype.UUID) (GetIssueUsageSummaryRow, error) {
row := q.db.QueryRow(ctx, getIssueUsageSummary, issueID)
var i GetIssueUsageSummaryRow
@@ -55,6 +82,9 @@ func (q *Queries) GetIssueUsageSummary(ctx context.Context, issueID pgtype.UUID)
&i.UncostedCacheReadTokens,
&i.UncostedCacheWriteTokens,
&i.TaskCount,
+ &i.TerminalTaskCount,
+ &i.MeteredTaskCount,
+ &i.UnreportedTaskCount,
)
return i, err
}
@@ -171,6 +201,9 @@ SELECT
0
)::bigint AS total_seconds,
COUNT(*)::int AS task_count,
+ COUNT(*) FILTER (WHERE EXISTS (
+ SELECT 1 FROM task_usage tu WHERE tu.task_id = atq.id
+ ))::int AS metered_task_count,
COUNT(*) FILTER (WHERE atq.status = 'failed')::int AS failed_count,
COUNT(*) FILTER (WHERE atq.status = 'cancelled')::int AS cancelled_count
FROM agent_task_queue atq
@@ -193,11 +226,12 @@ type ListDashboardAgentRunTimeParams struct {
}
type ListDashboardAgentRunTimeRow struct {
- AgentID pgtype.UUID `json:"agent_id"`
- TotalSeconds int64 `json:"total_seconds"`
- TaskCount int32 `json:"task_count"`
- FailedCount int32 `json:"failed_count"`
- CancelledCount int32 `json:"cancelled_count"`
+ AgentID pgtype.UUID `json:"agent_id"`
+ TotalSeconds int64 `json:"total_seconds"`
+ TaskCount int32 `json:"task_count"`
+ MeteredTaskCount int32 `json:"metered_task_count"`
+ FailedCount int32 `json:"failed_count"`
+ CancelledCount int32 `json:"cancelled_count"`
}
// Per-agent total task run time and task count for the workspace, optionally
@@ -208,6 +242,8 @@ type ListDashboardAgentRunTimeRow struct {
// ~= completion time).
//
// See ListDashboardRunTimeDaily for why 'cancelled' belongs in the filter.
+// metered_task_count uses task_usage row existence, not token totals, so a
+// provider-reported zero stays distinct from a run that reported nothing.
//
// No date bucketing, so no @tz — but @since is the viewer's local
// start-of-day for the EXACT N-day window (parseExactSinceParamInTZ), so the
@@ -227,6 +263,7 @@ func (q *Queries) ListDashboardAgentRunTime(ctx context.Context, arg ListDashboa
&i.AgentID,
&i.TotalSeconds,
&i.TaskCount,
+ &i.MeteredTaskCount,
&i.FailedCount,
&i.CancelledCount,
); err != nil {
diff --git a/server/pkg/db/queries/task_usage.sql b/server/pkg/db/queries/task_usage.sql
index 09b2c7fbe..588d47c9e 100644
--- a/server/pkg/db/queries/task_usage.sql
+++ b/server/pkg/db/queries/task_usage.sql
@@ -70,20 +70,44 @@ WHERE atq.agent_id = sqlc.arg('agent_id')
ORDER BY tu.task_id, tu.model;
-- name: GetIssueUsageSummary :one
+-- Keep the legacy usage aggregates intact, then report coverage over finite
+-- terminal runs separately. A task_usage row is the durable evidence that a
+-- run reported usage even when every token counter is legitimately zero.
+-- Both passes use the existing issue_id / task_id indexes (migrations 035/032).
+WITH usage AS (
+ SELECT
+ COALESCE(SUM(tu.input_tokens), 0)::bigint AS total_input_tokens,
+ COALESCE(SUM(tu.output_tokens), 0)::bigint AS total_output_tokens,
+ COALESCE(SUM(tu.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
+ COALESCE(SUM(tu.cache_write_tokens), 0)::bigint AS total_cache_write_tokens,
+ COALESCE(SUM(tu.cost_usd_ticks), 0)::bigint AS total_cost_usd_ticks,
+ COALESCE(SUM(tu.input_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_input_tokens,
+ COALESCE(SUM(tu.output_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_output_tokens,
+ COALESCE(SUM(tu.cache_read_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_read_tokens,
+ COALESCE(SUM(tu.cache_write_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_write_tokens,
+ COUNT(DISTINCT tu.task_id)::int AS task_count
+ FROM task_usage tu
+ JOIN agent_task_queue atq ON atq.id = tu.task_id
+ WHERE atq.issue_id = $1
+), terminal_runs AS (
+ SELECT
+ COUNT(*)::int AS terminal_task_count,
+ COUNT(*) FILTER (WHERE EXISTS (
+ SELECT 1 FROM task_usage tu WHERE tu.task_id = atq.id
+ ))::int AS metered_task_count
+ FROM agent_task_queue atq
+ WHERE atq.issue_id = $1
+ AND atq.status IN ('completed', 'failed', 'cancelled')
+ AND atq.started_at IS NOT NULL
+ AND atq.completed_at IS NOT NULL
+)
SELECT
- COALESCE(SUM(tu.input_tokens), 0)::bigint AS total_input_tokens,
- COALESCE(SUM(tu.output_tokens), 0)::bigint AS total_output_tokens,
- COALESCE(SUM(tu.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
- COALESCE(SUM(tu.cache_write_tokens), 0)::bigint AS total_cache_write_tokens,
- COALESCE(SUM(tu.cost_usd_ticks), 0)::bigint AS total_cost_usd_ticks,
- COALESCE(SUM(tu.input_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_input_tokens,
- COALESCE(SUM(tu.output_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_output_tokens,
- COALESCE(SUM(tu.cache_read_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_read_tokens,
- COALESCE(SUM(tu.cache_write_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_write_tokens,
- COUNT(DISTINCT tu.task_id)::int AS task_count
-FROM task_usage tu
-JOIN agent_task_queue atq ON atq.id = tu.task_id
-WHERE atq.issue_id = $1;
+ usage.*,
+ terminal_runs.terminal_task_count,
+ terminal_runs.metered_task_count,
+ (terminal_runs.terminal_task_count - terminal_runs.metered_task_count)::int AS unreported_task_count
+FROM usage
+CROSS JOIN terminal_runs;
-- name: ListDashboardUsageDaily :many
-- Daily per-(date, provider, model) token aggregates for the workspace, served
@@ -211,6 +235,8 @@ ORDER BY DATE(atq.completed_at AT TIME ZONE sqlc.arg('tz')::text) DESC;
-- ~= completion time).
--
-- See ListDashboardRunTimeDaily for why 'cancelled' belongs in the filter.
+-- metered_task_count uses task_usage row existence, not token totals, so a
+-- provider-reported zero stays distinct from a run that reported nothing.
--
-- No date bucketing, so no @tz — but @since is the viewer's local
-- start-of-day for the EXACT N-day window (parseExactSinceParamInTZ), so the
@@ -224,6 +250,9 @@ SELECT
0
)::bigint AS total_seconds,
COUNT(*)::int AS task_count,
+ COUNT(*) FILTER (WHERE EXISTS (
+ SELECT 1 FROM task_usage tu WHERE tu.task_id = atq.id
+ ))::int AS metered_task_count,
COUNT(*) FILTER (WHERE atq.status = 'failed')::int AS failed_count,
COUNT(*) FILTER (WHERE atq.status = 'cancelled')::int AS cancelled_count
FROM agent_task_queue atq