Skip to content

Commit 3072df4

Browse files
committed
fix(mothership): keep a closed tool meter final against a late tool completion
1 parent 03e1761 commit 3072df4

2 files changed

Lines changed: 19 additions & 10 deletions

File tree

‎apps/sim/lib/mothership/billing/service-store.integration.ts‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/** Local SQL verifies receipt durability, concurrent claims and replay independently of tool completion. */
22

3-
import { randomUUID } from 'node:crypto'
43
import { readFileSync } from 'node:fs'
4+
import { generateId } from '@sim/utils/id'
55
import type { Sql } from 'postgres'
66
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
77

@@ -68,14 +68,14 @@ describe('service receipts in SQL', () => {
6868
it('claims each known receipt once while preserving incomplete measurement and delivery failures', async () => {
6969
const client = state.client!
7070
const base = {
71-
streamId: randomUUID(),
71+
streamId: generateId(),
7272
toolCallId: 'tool',
7373
workerOrigin: 'http://127.0.0.1:8080',
7474
}
75-
const intentId = randomUUID()
75+
const intentId = generateId()
7676
await beginServiceMeter({ ...base, id: intentId })
7777
const receipt = {
78-
id: randomUUID(),
78+
id: generateId(),
7979
streamId: base.streamId,
8080
toolCallId: base.toolCallId,
8181
service: 'exa',
@@ -111,17 +111,17 @@ describe('service receipts in SQL', () => {
111111
it('closes each abandoned tool meter once and leaves in-flight meters and receipts open', async () => {
112112
const client = state.client!
113113
const scope = {
114-
streamId: randomUUID(),
114+
streamId: generateId(),
115115
toolCallId: 'abandoned-tool',
116116
workerOrigin: 'http://127.0.0.1:8080',
117117
}
118-
const abandoned = randomUUID()
119-
const failed = randomUUID()
120-
const inFlight = randomUUID()
118+
const abandoned = generateId()
119+
const failed = generateId()
120+
const inFlight = generateId()
121121
for (const id of [abandoned, failed, inFlight]) await beginServiceMeter({ ...scope, id })
122122
await finishServiceUsage(failed, 'provider pricing unavailable')
123123
const receipt = {
124-
id: randomUUID(),
124+
id: generateId(),
125125
streamId: scope.streamId,
126126
toolCallId: scope.toolCallId,
127127
service: 'exa',
@@ -144,6 +144,14 @@ describe('service receipts in SQL', () => {
144144
)
145145
expect(closed.every((meter) => meter.streamId === scope.streamId)).toBe(true)
146146
expect(await closeAbandonedServiceMeters()).toEqual([])
147+
// The watchdog only stops the chat waiting, so the owner can still finish after the close.
148+
await finishServiceUsage(abandoned)
149+
await finishServiceUsage(failed, 'late failure')
150+
const lateRows =
151+
await client`SELECT id, last_error FROM copilot_service_usage WHERE id IN ${client([abandoned, failed])}`
152+
expect(new Map(lateRows.map((row) => [row.id, row.last_error]))).toEqual(
153+
new Map(closed.map((meter) => [meter.id, meter.lastError]))
154+
)
147155

148156
const open =
149157
await client`SELECT id FROM copilot_service_usage WHERE stream_id = ${scope.streamId} AND delivered_at IS NULL`

‎apps/sim/lib/mothership/billing/service-store.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,12 @@ export async function claimServiceUsage(limit = 10) {
4242
})
4343
}
4444

45+
/** A closed row is final, so a tool that outlived its watchdog cannot rewrite its close. */
4546
export async function finishServiceUsage(id: string, error?: string): Promise<void> {
4647
await db
4748
.update(copilotServiceUsage)
4849
.set(error ? { lastError: error } : { deliveredAt: new Date(), lastError: null })
49-
.where(eq(copilotServiceUsage.id, id))
50+
.where(and(eq(copilotServiceUsage.id, id), isNull(copilotServiceUsage.deliveredAt)))
5051
}
5152

5253
export async function beginServiceMeter(input: {

0 commit comments

Comments
 (0)