From f8f59215913c880d1e2aad26d27142477d065e74 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:41:16 +0000 Subject: [PATCH] fix: retain deleted task inference costs --- .../lib/server/analytics/cost-rows.test.ts | 84 +++++++++++++++++++ .../web/src/lib/server/analytics/cost-rows.ts | 39 ++++++--- 2 files changed, 110 insertions(+), 13 deletions(-) diff --git a/apps/web/src/lib/server/analytics/cost-rows.test.ts b/apps/web/src/lib/server/analytics/cost-rows.test.ts index 6b95fa597..80030144e 100644 --- a/apps/web/src/lib/server/analytics/cost-rows.test.ts +++ b/apps/web/src/lib/server/analytics/cost-rows.test.ts @@ -2,6 +2,7 @@ import { db, environmentFactory, environments, + eq, fastAgentConversations, fastAgentMessages, inArray, @@ -249,6 +250,89 @@ describe('getCostAnalyticsRows', () => { expect(row?.meta?.prKeys).toEqual(['github:github.com:roomote/test#42']); }); + it('retains deleted-task spend without exposing task attribution', async () => { + const user = await userFactory.create(); + userIds.push(user.id); + const task = await taskFactory.create({ + initiatorUserId: user.id, + title: 'Private deleted task title', + }); + taskIds.push(task.id); + await db.insert(taskPullRequests).values({ + taskId: task.id, + prUrl: 'https://github.com/roomote/private/pull/99', + prNumber: 99, + repository: 'roomote/private', + sourceControlProvider: 'github', + host: 'github.com', + }); + const [usageEvent] = await db + .insert(llmUsageEvents) + .values({ + eventKey: `deleted-task-cost-analytics-${crypto.randomUUID()}`, + taskId: task.id, + userId: user.id, + costSource: 'opencode_message', + costMicroUsd: 40_000_000, + totalTokens: 12_345, + messageCompletedAt: new Date('2026-07-15T12:00:00.000Z'), + }) + .returning({ id: llmUsageEvents.id }); + usageEventIds.push(usageEvent!.id); + await db + .update(tasks) + .set({ deletedAt: new Date('2026-07-16T12:00:00.000Z') }) + .where(eq(tasks.id, task.id)); + + const rows = await getCostAnalyticsRows( + {} as UserAuthSuccess, + 'all', + new Date('2026-07-17T12:00:00.000Z'), + ); + const row = rows.find((candidate) => candidate.id === usageEvent!.id)!; + const chart = buildChartData( + [row], + 'costs', + 'taskType', + 'cost', + 'all', + 'day', + new Date('2026-07-17T12:00:00.000Z'), + ); + + expect(row).toMatchObject({ + value: 40, + tokens: 12_345, + dimensions: { + taskType: { key: 'Deleted task', label: 'Deleted task' }, + user: { key: '—', label: '—' }, + }, + details: { + values: { + user: '—', + taskType: 'Deleted task', + taskTitle: 'Deleted task', + cost: '40.00', + tokens: '12345', + }, + }, + meta: { + canonicalTaskId: task.id, + prKeys: [], + }, + }); + expect(row.details.links).toBeUndefined(); + expect(JSON.stringify(row)).not.toContain('Private deleted task title'); + expect(JSON.stringify(row)).not.toContain('roomote/private'); + expect(chart.total).toBe(40); + expect(chart.tokenTotal).toBe(12_345); + expect(chart.costSummary).toMatchObject({ + totalInferenceCost: 40, + taskCount: 1, + prCount: 0, + }); + }); + it('includes Fast parent and advisor/judge usage in Costs', async () => { const user = await userFactory.create(); userIds.push(user.id); diff --git a/apps/web/src/lib/server/analytics/cost-rows.ts b/apps/web/src/lib/server/analytics/cost-rows.ts index 7aa9d6b14..aca5b9970 100644 --- a/apps/web/src/lib/server/analytics/cost-rows.ts +++ b/apps/web/src/lib/server/analytics/cost-rows.ts @@ -127,6 +127,7 @@ export async function getCostAnalyticsRows( modelId: llmUsageEvents.modelId, environmentName: environments.name, taskTitle: tasks.title, + taskDeletedAt: tasks.deletedAt, initiatorKind: tasks.initiatorKind, initiatorAutomation: tasks.initiatorAutomation, actorDisplayName: tasks.actorDisplayName, @@ -148,7 +149,7 @@ export async function getCostAnalyticsRows( ) .leftJoin(taskRuns, eq(taskRuns.id, llmUsageEvents.runId)) .leftJoin(environments, eq(environments.id, llmUsageEvents.environmentId)) - .where(and(isNull(tasks.deletedAt), usageCutoffCondition)); + .where(usageCutoffCondition); const fallbackEnvironmentIds = [ ...new Set( @@ -249,23 +250,30 @@ export async function getCostAnalyticsRows( return usageRows.map((row) => { const isTask = Boolean(row.taskId); + const isDeletedTask = isTask && Boolean(row.taskDeletedAt); const isMemory = !isTask && row.source === 'brain_synthesis'; const isSession = !isTask && !isMemory && fastNativeSessionIds.has(row.harnessSessionId ?? ''); - const taskType = isTask - ? getTaskTypeDimensionValue({ - initiatorKind: row.initiatorKind, - initiatorAutomation: row.initiatorAutomation, - actorDisplayName: row.actorDisplayName, - }) - : createLabelBackedDimensionValue( - isMemory ? 'Memories' : isSession ? 'Session' : 'Non-task inference', - ); + const taskType = isDeletedTask + ? createLabelBackedDimensionValue('Deleted task') + : isTask + ? getTaskTypeDimensionValue({ + initiatorKind: row.initiatorKind, + initiatorAutomation: row.initiatorAutomation, + actorDisplayName: row.actorDisplayName, + }) + : createLabelBackedDimensionValue( + isMemory + ? 'Memories' + : isSession + ? 'Session' + : 'Non-task inference', + ); const attributedUserId = row.userId ?? row.taskUserId; const userDimension = - isTask && row.initiatorKind === 'automation' + isDeletedTask || (isTask && row.initiatorKind === 'automation') ? createLabelBackedDimensionValue(NO_VALUE_LABEL) : attributedUserId ? getCanonicalUserDimensionValue({ @@ -309,9 +317,14 @@ export async function getCostAnalyticsRows( model, cost: cost.toFixed(2), tokens: String(tokens), - taskTitle: row.taskTitle ?? taskType.label, + taskTitle: isDeletedTask + ? taskType.label + : (row.taskTitle ?? taskType.label), }, - links: row.taskId ? { task: `/task/${row.taskId}` } : undefined, + links: + row.taskId && !isDeletedTask + ? { task: `/task/${row.taskId}` } + : undefined, }, meta: { canonicalTaskId: row.taskId,