mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +08:00
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:
co-authored by
multica-agent
Sol-Boy
parent
0c886a1c9e
commit
c03633d64a
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"}`}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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": "이 기간에는 에이전트 활동이 없습니다."
|
||||
|
||||
@@ -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": "所选时间范围内暂无智能体活动。"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user