Skip to content

Commit ab2a5c0

Browse files
authored
fix(assistant-actions): read a run that closes mid-review as nothing to review (#9721)
* fix(assistant-actions): read a run that closes mid-review as nothing to review * fix(assistant-actions): return a fresh empty review result
1 parent 7625929 commit ab2a5c0

2 files changed

Lines changed: 65 additions & 3 deletions

File tree

‎apps/sim/lib/assistant-actions/application/review.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ interface ReviewInput {
3838
runId: string
3939
}
4040

41+
function nothingToReview() {
42+
return { runId: null, streamId: null, actions: [], nextCursor: null }
43+
}
44+
4145
export const listOrganizationAssistantActions = defineAuthorizedOrganizationUseCase({
4246
operation: assistantActionOperations.list,
4347
async execute({
@@ -64,9 +68,16 @@ export const listOrganizationAssistantActions = defineAuthorizedOrganizationUseC
6468
)
6569
.orderBy(desc(copilotRuns.startedAt), desc(copilotRuns.id))
6670
.limit(1)
67-
if (!latest) return { runId: null, streamId: null, actions: [], nextCursor: null }
71+
if (!latest) return nothingToReview()
6872
const runId = latest.id
69-
const run = await requireAssistantRun(principal, { ...input, runId }, context.userId)
73+
const run = await requireAssistantRun(principal, { ...input, runId }, context.userId).catch(
74+
(error: unknown) => {
75+
// The lookup above takes no lock, so the run can close before this one is granted.
76+
if (error instanceof OrchestrationError && error.code === 'conflict') return null
77+
throw error
78+
}
79+
)
80+
if (!run) return nothingToReview()
7081
const records = await db
7182
.select()
7283
.from(copilotAsyncToolCalls)

‎apps/sim/lib/assistant-actions/tool-permissions.integration.ts‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ import {
2525
} from '@sim/db/schema'
2626
import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure'
2727
import { sha256Hex } from '@sim/security/hash'
28+
import { createDeferred } from '@sim/testing/helpers/deferred'
2829
import { generateId } from '@sim/utils/id'
2930
import { toArray, toRecord } from '@sim/utils/object'
30-
import { eq, inArray } from 'drizzle-orm'
31+
import { eq, inArray, sql } from 'drizzle-orm'
3132
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
3233
import {
3334
claimOrganizationAssistantAction,
@@ -639,6 +640,56 @@ describe('organization Assistant tool permissions', () => {
639640
}
640641
)
641642

643+
it('reads a run that closes during the review read as nothing to review', async () => {
644+
await change(0, 'ask')
645+
await prepare()
646+
const held = createDeferred<number>()
647+
const release = createDeferred<void>()
648+
const closing = db.transaction(async (tx) => {
649+
const [blocker] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`)
650+
if (!blocker) throw new Error('Missing closing transaction')
651+
await tx.update(copilotChats).set({ conversationId: null }).where(eq(copilotChats.id, chatId))
652+
await tx
653+
.update(copilotRuns)
654+
.set({ status: 'complete', toolAdmissionClosedAt: new Date() })
655+
.where(eq(copilotRuns.id, runId))
656+
held.resolve(blocker.pid)
657+
await release.promise
658+
})
659+
void closing.catch((error: unknown) => held.reject(error))
660+
try {
661+
const blockerPid = await held.promise
662+
const reading = review()
663+
void reading.catch(() => undefined)
664+
await vi.waitFor(
665+
async () => {
666+
const [waiting] = await db.execute<{ pid: number }>(sql`
667+
SELECT pid FROM pg_stat_activity
668+
WHERE datname = current_database()
669+
AND ${blockerPid}::int = ANY(pg_blocking_pids(pid))
670+
`)
671+
expect(waiting).toBeDefined()
672+
},
673+
{ timeout: 10_000, interval: 25 }
674+
)
675+
release.resolve()
676+
await closing
677+
expect(await reading).toEqual({
678+
runId: null,
679+
streamId: null,
680+
actions: [],
681+
nextCursor: null,
682+
})
683+
} finally {
684+
release.resolve()
685+
await Promise.allSettled([closing])
686+
await db
687+
.update(copilotChats)
688+
.set({ conversationId: streamId })
689+
.where(eq(copilotChats.id, chatId))
690+
}
691+
})
692+
642693
it('pages eligible proposals past expired rows and keeps canonical turn metadata', async () => {
643694
await change(0, 'ask')
644695
const expired = []

0 commit comments

Comments
 (0)