@@ -30,25 +30,32 @@ const { redisUrl, inheritedEnv, worker } = await vi.hoisted(async () => {
3030
3131import { db } from '@sim/db'
3232import {
33+ copilotAsyncToolCalls ,
3334 copilotChats ,
3435 copilotRequestStops ,
3536 copilotRuns ,
3637 permissions ,
3738 user ,
3839 workspace ,
3940} from '@sim/db/schema'
41+ import { createDeferred } from '@sim/testing'
4042import { sleep } from '@sim/utils/helpers'
4143import { generateId } from '@sim/utils/id'
4244import { randomInt } from '@sim/utils/random'
4345import { eq , inArray , sql } from 'drizzle-orm'
4446import { closeRedisConnection , getRedisClient } from '@/lib/core/config/redis'
47+ import type { DbTransaction } from '@/lib/db/types'
4548import {
4649 LEGACY_RUN_ERROR ,
4750 ORPHANED_RUN_ERROR ,
4851 settleStoppedRunWithoutController ,
4952 sweepOrphanedRuns ,
5053} from '@/lib/mothership/async-runs/orphaned-runs'
51- import { requestRunStop , updateRunStatus } from '@/lib/mothership/async-runs/repository'
54+ import {
55+ claimSimToolExecution ,
56+ requestRunStop ,
57+ updateRunStatus ,
58+ } from '@/lib/mothership/async-runs/repository'
5259import { chatPubSub } from '@/lib/mothership/chat-status'
5360import { abortRun } from '@/lib/mothership/request/application/controls'
5461import { claimRunController } from '@/lib/mothership/request/lifecycle/controller-ownership'
@@ -184,6 +191,64 @@ describe.runIf(Boolean(redisUrl))('Chat runs no controller owns', () => {
184191 await requestRunStop ( { userId, workspaceId, streamId : run . streamId , chatId : run . chatId } )
185192 }
186193
194+ /** A Sim tool call the worker dispatched on the run, not yet admitted for execution. */
195+ async function dispatchedTool ( runId : string ) {
196+ const toolCallId = generateId ( )
197+ await db . insert ( copilotAsyncToolCalls ) . values ( { runId, toolCallId, toolName : 'run_workflow' } )
198+ return { toolCallId, runId, userId, ownerToken : generateId ( ) }
199+ }
200+
201+ /** The backend queued on a lock behind any of these, once one is. */
202+ async function lockWaiterBehind ( ...blockers : number [ ] ) {
203+ const pids = sql `ARRAY[${ sql . join (
204+ blockers . map ( ( pid ) => sql `${ pid } ::int` ) ,
205+ sql `, `
206+ ) } ]`
207+ let waiter : number | undefined
208+ await expect
209+ . poll (
210+ async ( ) => {
211+ const [ row ] = await db . execute < { pid : number } > ( sql `
212+ SELECT pid FROM pg_stat_activity WHERE datname = current_database()
213+ AND wait_event_type = 'Lock' AND pid <> ALL(${ pids } )
214+ AND pg_blocking_pids(pid) && ${ pids } LIMIT 1
215+ ` )
216+ waiter = row ?. pid
217+ return waiter
218+ } ,
219+ { interval : 5 , timeout : 5000 }
220+ )
221+ . toBeDefined ( )
222+ return waiter !
223+ }
224+
225+ /**
226+ * Runs `lock` in a transaction held open until `run` settles, then commits it, so a
227+ * failed step never leaves the rows locked behind the test. `run` returns the work
228+ * queued behind the lock wrapped, never as a bare promise it would wait on.
229+ */
230+ async function whileHolding < T > (
231+ lock : ( tx : DbTransaction ) => Promise < unknown > ,
232+ run : ( holder : number ) => Promise < T >
233+ ) : Promise < T > {
234+ const locked = createDeferred < number > ( )
235+ const release = createDeferred < void > ( )
236+ const holding = db . transaction ( async ( tx ) => {
237+ await lock ( tx )
238+ const [ backend ] = await tx . execute < { pid : number } > ( sql `SELECT pg_backend_pid() AS pid` )
239+ locked . resolve ( backend . pid )
240+ await release . promise
241+ } )
242+ holding . catch ( locked . reject )
243+ const holder = await locked . promise
244+ try {
245+ return await run ( holder )
246+ } finally {
247+ release . resolve ( )
248+ await holding
249+ }
250+ }
251+
187252 async function stored ( runId : string ) {
188253 const [ run ] = await db . select ( ) . from ( copilotRuns ) . where ( eq ( copilotRuns . id , runId ) )
189254 const [ chat ] = await db
@@ -207,6 +272,87 @@ describe.runIf(Boolean(redisUrl))('Chat runs no controller owns', () => {
207272 expect ( run . marker ) . toBeNull ( )
208273 } )
209274
275+ it ( 'never settles a run while one of its Sim tools holds a live execution lease' , async ( ) => {
276+ /** A long tool call writes nothing to the run; only its execution heartbeat shows it is alive. */
277+ const orphan = await admittedRun ( { idleMinutes : 90 , status : 'paused_waiting_for_tool' } )
278+ const tool = await dispatchedTool ( orphan . runId )
279+ expect ( await claimSimToolExecution ( tool ) ) . toEqual ( { outcome : 'claimed' } )
280+
281+ expect ( ( await sweepOrphanedRuns ( ) ) . settledRunIds ) . not . toContain ( orphan . runId )
282+ const live = await stored ( orphan . runId )
283+ expect ( live . status ) . toBe ( 'paused_waiting_for_tool' )
284+ expect ( live . toolAdmissionClosedAt ) . toBeNull ( )
285+ expect ( live . marker ) . toBe ( orphan . streamId )
286+
287+ /** Its owner died: the heartbeat stopped renewing the lease. */
288+ await db
289+ . update ( copilotAsyncToolCalls )
290+ . set ( { executionLeaseExpiresAt : sql `now() - interval '1 second'` } )
291+ . where ( eq ( copilotAsyncToolCalls . toolCallId , tool . toolCallId ) )
292+
293+ expect ( ( await sweepOrphanedRuns ( ) ) . settledRunIds ) . toContain ( orphan . runId )
294+ expect ( ( await stored ( orphan . runId ) ) . status ) . toBe ( 'error' )
295+ } )
296+
297+ it ( 'never settles a run whose Sim tool was admitted while the sweep waited to settle it' , async ( ) => {
298+ const orphan = await admittedRun ( { idleMinutes : 90 , status : 'paused_waiting_for_tool' } )
299+ const tool = await dispatchedTool ( orphan . runId )
300+ /** Holds the run row so the tool's admission and then the sweep queue behind it, in that order. */
301+ const { claim, sweep } = await whileHolding (
302+ ( tx ) =>
303+ tx
304+ . select ( { id : copilotRuns . id } )
305+ . from ( copilotRuns )
306+ . where ( eq ( copilotRuns . id , orphan . runId ) )
307+ . for ( 'update' ) ,
308+ async ( holder ) => {
309+ const claim = claimSimToolExecution ( tool )
310+ const claimant = await lockWaiterBehind ( holder )
311+ const sweep = sweepOrphanedRuns ( )
312+ await lockWaiterBehind ( holder , claimant )
313+ return { claim, sweep }
314+ }
315+ )
316+
317+ expect ( await claim ) . toEqual ( { outcome : 'claimed' } )
318+ expect ( ( await sweep ) . settledRunIds ) . not . toContain ( orphan . runId )
319+ const run = await stored ( orphan . runId )
320+ expect ( run . status ) . toBe ( 'paused_waiting_for_tool' )
321+ expect ( run . toolAdmissionClosedAt ) . toBeNull ( )
322+ } )
323+
324+ it ( 'never settles a run whose Sim tool lease a heartbeat renewed as the sweep settled it' , async ( ) => {
325+ const orphan = await admittedRun ( { idleMinutes : 90 , status : 'paused_waiting_for_tool' } )
326+ const tool = await dispatchedTool ( orphan . runId )
327+ expect ( await claimSimToolExecution ( tool ) ) . toEqual ( { outcome : 'claimed' } )
328+ await db
329+ . update ( copilotAsyncToolCalls )
330+ . set ( { executionLeaseExpiresAt : sql `clock_timestamp() - interval '1 second'` } )
331+ . where ( eq ( copilotAsyncToolCalls . toolCallId , tool . toolCallId ) )
332+
333+ /**
334+ * A heartbeat that passed its expiry check just before the lease ran out, and has
335+ * not committed yet: the sweep sees the old, expired lease until it does.
336+ */
337+ const { sweep } = await whileHolding (
338+ ( tx ) =>
339+ tx
340+ . update ( copilotAsyncToolCalls )
341+ . set ( { executionLeaseExpiresAt : sql `clock_timestamp() + interval '1 minute'` } )
342+ . where ( eq ( copilotAsyncToolCalls . toolCallId , tool . toolCallId ) ) ,
343+ async ( holder ) => {
344+ const sweep = sweepOrphanedRuns ( )
345+ await lockWaiterBehind ( holder )
346+ return { sweep }
347+ }
348+ )
349+
350+ expect ( ( await sweep ) . settledRunIds ) . not . toContain ( orphan . runId )
351+ const run = await stored ( orphan . runId )
352+ expect ( run . status ) . toBe ( 'paused_waiting_for_tool' )
353+ expect ( run . toolAdmissionClosedAt ) . toBeNull ( )
354+ } )
355+
210356 it ( 'settles a run stopped while no controller owned it as cancelled' , async ( ) => {
211357 const orphan = await admittedRun ( { idleMinutes : 90 , stopped : true } )
212358
0 commit comments