diff --git a/packages/core/api/schemas.test.ts b/packages/core/api/schemas.test.ts index 05d0bef34..8ed605826 100644 --- a/packages/core/api/schemas.test.ts +++ b/packages/core/api/schemas.test.ts @@ -1062,6 +1062,34 @@ describe("dashboard + runtime usage schema drift", () => { ).toBe(0); }); + it("preserves optional usage coverage without rejecting older or malformed rows", () => { + const parsed = DashboardAgentRunTimeListSchema.parse([ + { + agent_id: "new-server", + total_seconds: 42, + task_count: 3, + metered_task_count: 2, + failed_count: 0, + }, + { + agent_id: "old-server", + total_seconds: 42, + task_count: 3, + failed_count: 0, + }, + { + agent_id: "drifted-server", + total_seconds: 42, + task_count: 3, + metered_task_count: "not-a-number", + failed_count: 0, + }, + ]); + expect(parsed[0]?.metered_task_count).toBe(2); + expect(parsed[1]?.metered_task_count).toBeUndefined(); + expect(parsed[2]?.metered_task_count).toBeUndefined(); + }); + it("coerces a missing agent_id key to \"\" for the usage-by-agent panel", () => { const parsed = DashboardUsageByAgentListSchema.parse([ { model: "claude-opus-4-7", input_tokens: 7 }, diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 7f73ad490..2df1fbc61 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -1585,6 +1585,7 @@ const DashboardAgentRunTimeSchema = z.object({ agent_id: z.string().default(""), total_seconds: z.number().default(0), task_count: z.number().default(0), + metered_task_count: z.number().optional().catch(undefined), failed_count: z.number().default(0), cancelled_count: z.number().default(0), }).loose(); diff --git a/packages/core/types/agent.ts b/packages/core/types/agent.ts index c331982c5..1b34adc10 100644 --- a/packages/core/types/agent.ts +++ b/packages/core/types/agent.ts @@ -899,6 +899,11 @@ export interface IssueUsageSummary { uncosted_output_tokens?: number; uncosted_cache_read_tokens?: number; uncosted_cache_write_tokens?: number; + // Coverage fields are optional for compatibility with older backends. + // task_count remains the legacy count of runs represented by usage rows. + terminal_task_count?: number; + metered_task_count?: number; + unreported_task_count?: number; task_count: number; } @@ -1021,6 +1026,10 @@ export interface DashboardAgentRunTime { agent_id: string; total_seconds: number; task_count: number; + // Optional for compatibility with backends predating usage-coverage + // reporting. Consumers can still identify the fully-unreported case when + // this is absent by checking whether the agent has any usage rows. + metered_task_count?: number; failed_count: number; // Runs the user stopped mid-flight. Disjoint from `failed_count`, and // both are subsets of `task_count` — the succeeded count is the diff --git a/packages/views/dashboard/components/dashboard-page.test.tsx b/packages/views/dashboard/components/dashboard-page.test.tsx index d8f3b5f21..23f14c0cf 100644 --- a/packages/views/dashboard/components/dashboard-page.test.tsx +++ b/packages/views/dashboard/components/dashboard-page.test.tsx @@ -23,6 +23,12 @@ const manyAgentsRef = vi.hoisted(() => ({ current: false })); // — what a plain member actually receives once the backend folds the agents // they may not view (MUL-5409). const restrictedBucketRef = vi.hoisted(() => ({ current: false })); +// Simulates the real-time task_usage coverage field independently from the +// delayed by-agent aggregate. Undefined keeps the default fixture on the old +// server compatibility path. +const runtimeMeteredCountRef = vi.hoisted(() => ({ + current: undefined as number | undefined, +})); // Kept out of the fixture ternary so the sentinel's shape reads at a glance. // Unlike the deleted-agents bucket this one carries real seconds / tasks: the @@ -148,6 +154,7 @@ vi.mock("@tanstack/react-query", async () => { agent_id: "agent-1", total_seconds: 3 * 3_600 + 17 * 60, task_count: 12, + metered_task_count: runtimeMeteredCountRef.current, failed_count: 1, }, ] @@ -382,6 +389,69 @@ describe("DashboardPage — viewing timezone drives the query key", () => { }); }); +describe("DashboardPage — unreported usage", () => { + beforeEach(() => { + queryKeys.length = 0; + dashboardDataRef.current = true; + manyAgentsRef.current = false; + restrictedBucketRef.current = false; + runtimeMeteredCountRef.current = undefined; + tzRef.current = "UTC"; + cleanup(); + }); + + it("keeps the run visible while replacing invented token and cost zeroes", () => { + runtimeMeteredCountRef.current = 0; + renderDashboard(); + + const list = within(screen.getByRole("list", { name: "Leaderboard" })); + const row = list.getAllByRole("listitem")[0] as HTMLElement; + expect(row).toHaveTextContent("Agent One"); + expect(row).toHaveTextContent("12 runs did not report usage"); + expect(within(row).getAllByText("—")).toHaveLength(2); + expect(row).toHaveTextContent("3h 17m"); + expect(row).toHaveTextContent("12"); + }); + + it("shows rollup lag as pending without inventing zero or an unreported run", () => { + runtimeMeteredCountRef.current = 12; + renderDashboard(); + + const list = within(screen.getByRole("list", { name: "Leaderboard" })); + const row = list.getAllByRole("listitem")[0] as HTMLElement; + expect(row).toHaveTextContent("Usage totals are still being processed"); + expect(row).not.toHaveTextContent("did not report usage"); + expect(within(row).getAllByText("—")).toHaveLength(2); + }); + + it("keeps old-server coverage unknown without claiming unreported usage", () => { + runtimeMeteredCountRef.current = undefined; + renderDashboard(); + + const list = within(screen.getByRole("list", { name: "Leaderboard" })); + const row = list.getAllByRole("listitem")[0] as HTMLElement; + expect(row).not.toHaveTextContent("did not report usage"); + expect(row).not.toHaveTextContent("still being processed"); + expect(within(row).getAllByText("—")).toHaveLength(2); + }); + + it("shows both missing coverage and delayed totals", () => { + runtimeMeteredCountRef.current = 6; + renderDashboard(); + + const list = within(screen.getByRole("list", { name: "Leaderboard" })); + const row = list.getAllByRole("listitem")[0] as HTMLElement; + const coverageText = + "6 runs did not report usage · totals are still being processed"; + expect(row).toHaveTextContent(coverageText); + expect(within(row).getByText(coverageText)).toHaveAttribute( + "title", + coverageText, + ); + expect(within(row).getAllByText("—")).toHaveLength(2); + }); +}); + describe("DashboardPage — failure visibility", () => { beforeEach(() => { queryKeys.length = 0; diff --git a/packages/views/dashboard/components/leaderboard.tsx b/packages/views/dashboard/components/leaderboard.tsx index 8bb4e4a2a..c376729cd 100644 --- a/packages/views/dashboard/components/leaderboard.tsx +++ b/packages/views/dashboard/components/leaderboard.tsx @@ -106,6 +106,22 @@ export function Leaderboard({ const colClass = (key: LeaderboardSort) => `text-right ${sortBy === key ? "text-foreground" : "text-muted-foreground"}`; + const getCoverageText = ( + unreportedTaskCount: number, + totalsPending: boolean, + ) => { + if (unreportedTaskCount > 0) { + return totalsPending + ? t(($) => $.leaderboard.usage_unreported_pending, { + count: unreportedTaskCount, + }) + : t(($) => $.leaderboard.usage_unreported, { + count: unreportedTaskCount, + }); + } + return totalsPending ? t(($) => $.leaderboard.usage_totals_pending) : null; + }; + return (
@@ -200,6 +216,20 @@ export function Leaderboard({ const agent = agents.find((a) => a.id === row.agentId); const value = SORT_METRIC[sortBy](row); const pct = maxValue > 0 ? (value / maxValue) * 100 : 0; + const usageUnavailable = !row.hasUsageTotals; + const usageIncomplete = row.unreportedTaskCount > 0; + const usageTotalsPending = + row.hasReportedUsage && !row.hasUsageTotals; + const tokenText = usageUnavailable + ? "—" + : `${usageIncomplete ? "≥" : ""}${formatTokens(row.tokens)}`; + const costText = usageUnavailable + ? "—" + : `${usageIncomplete ? "≥" : ""}$${row.cost.toFixed(2)}`; + const coverageText = getCoverageText( + row.unreportedTaskCount, + usageTotalsPending, + ); return (
  • )} - - {isDeletedBucket - ? t(($) => $.leaderboard.deleted_agents) - : t(($) => $.leaderboard.other_agents)} + + + {isDeletedBucket + ? t(($) => $.leaderboard.deleted_agents) + : t(($) => $.leaderboard.other_agents)} + + {coverageText ? ( + + {coverageText} + + ) : null} ) : ( @@ -230,8 +270,18 @@ export function Leaderboard({ size="md" enableHoverCard /> - - {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