MUL-7202: fix(usage): distinguish unreported agent runs (#8241)

* fix(usage): distinguish unreported agent runs

Co-authored-by: multica-agent <[email protected]>

* fix(usage): handle dashboard rollup lag

Co-authored-by: multica-agent <[email protected]>

* fix(usage): preserve nonterminal usage totals

Co-authored-by: multica-agent <[email protected]>

* fix(usage): expose truncated coverage details

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Multica Eve
2026-09-10 16:26:38 +08:00
committed by GitHub
co-authored by multica-agent Sol-Boy
parent 0c886a1c9e
commit c03633d64a
20 changed files with 758 additions and 82 deletions
+28
View File
@@ -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 },
+1
View File
@@ -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();
+9
View File
@@ -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
@@ -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;
@@ -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 (
<div className="rounded-lg border bg-card">
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 pt-4 pb-3">
@@ -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 (
<li
key={row.agentId}
@@ -216,10 +246,20 @@ export function Leaderboard({
<EyeOff className="h-3 w-3" />
)}
</span>
<span className="truncate text-body font-medium italic text-muted-foreground">
{isDeletedBucket
? t(($) => $.leaderboard.deleted_agents)
: t(($) => $.leaderboard.other_agents)}
<span className="min-w-0">
<span className="block truncate text-body font-medium italic text-muted-foreground">
{isDeletedBucket
? t(($) => $.leaderboard.deleted_agents)
: t(($) => $.leaderboard.other_agents)}
</span>
{coverageText ? (
<span
className="block truncate text-caption text-muted-foreground"
title={coverageText}
>
{coverageText}
</span>
) : null}
</span>
</>
) : (
@@ -230,8 +270,18 @@ export function Leaderboard({
size="md"
enableHoverCard
/>
<span className="cursor-pointer truncate text-body font-medium">
{agent?.name ?? row.agentId}
<span className="min-w-0">
<span className="block cursor-pointer truncate text-body font-medium">
{agent?.name ?? row.agentId}
</span>
{coverageText ? (
<span
className="block truncate text-caption text-muted-foreground"
title={coverageText}
>
{coverageText}
</span>
) : null}
</span>
</>
)}
@@ -245,12 +295,12 @@ export function Leaderboard({
<div
className={`text-right text-caption tabular-nums ${sortBy === "tokens" ? "font-medium text-foreground" : "text-muted-foreground"}`}
>
{formatTokens(row.tokens)}
{tokenText}
</div>
<div
className={`text-right tabular-nums ${sortBy === "cost" ? "text-body font-medium" : "text-caption text-muted-foreground"}`}
>
${row.cost.toFixed(2)}
{costText}
</div>
<div
className={`text-right text-caption tabular-nums ${sortBy === "time" ? "font-medium text-foreground" : "text-muted-foreground"}`}
+132 -3
View File
@@ -197,6 +197,9 @@ describe("mergeAgentDashboardRows", () => {
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],
+40 -7
View File
@@ -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<string, AgentDashboardRow>();
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;
}
+5
View File
@@ -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."
+3
View File
@@ -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": "この期間にエージェントの活動はありません。"
+3
View File
@@ -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": "이 기간에는 에이전트 활동이 없습니다."
@@ -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": "所选时间范围内暂无智能体活动。"
+46 -10
View File
@@ -346,8 +346,11 @@ var issueRunMessagesCmd = &cobra.Command{
var issueUsageCmd = &cobra.Command{
Use: "usage <issue-id>",
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 {
+117 -2
View File
@@ -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",
+7 -1
View File
@@ -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,
})
}
+58
View File
@@ -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")
+13 -10
View File
@@ -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
@@ -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.
+56
View File
@@ -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.
+55 -18
View File
@@ -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 {
+42 -13
View File
@@ -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