Skip to content

Commit 18f650f

Browse files
committed
fix(mothership): let the server re-claim a workflow call its dead claimant never started
1 parent 79f0d27 commit 18f650f

2 files changed

Lines changed: 61 additions & 1 deletion

File tree

‎apps/sim/lib/mothership/async-runs/repository.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
inArray,
2222
isNotNull,
2323
isNull,
24+
like,
2425
lt,
2526
ne,
2627
notInArray,
@@ -1591,7 +1592,23 @@ export async function claimWorkflowToolExecution(
15911592
)
15921593
),
15931594
/** Only the server re-runs a workflow whose Sim executor died; no browser resumes it. */
1594-
executor === 'sim' ? interruptedSimExecution : undefined
1595+
executor === 'sim' ? interruptedSimExecution : undefined,
1596+
/**
1597+
* A server claim whose claimant died before starting the execution. Starting is
1598+
* fenced by `claimToolExecution`, so a claimant still alive and this one cannot
1599+
* both run it: the second to start observes the first's result.
1600+
*/
1601+
executor === 'sim'
1602+
? and(
1603+
like(copilotAsyncToolCalls.claimedBy, `${WORKFLOW_EXECUTION_CLAIM_PREFIX}%`),
1604+
isNull(copilotAsyncToolCalls.executionStartedAt),
1605+
isNull(copilotAsyncToolCalls.clientWorkflowExecutionId),
1606+
inArray(copilotAsyncToolCalls.status, [
1607+
ASYNC_TOOL_STATUS.running,
1608+
ASYNC_TOOL_STATUS.delivered,
1609+
])
1610+
)
1611+
: undefined
15951612
)
15961613
)
15971614
)

‎apps/sim/lib/mothership/request/application/abandoned-runs.integration.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,4 +588,47 @@ describe.runIf(Boolean(redisUrl))('taking over a run whose controller died', ()
588588
await db.update(copilotRuns).set({ status: 'cancelled' }).where(eq(copilotRuns.id, runId))
589589
}
590590
})
591+
592+
it('re-claims a server workflow claim whose claimant died before starting it, once', async () => {
593+
const chatId = generateId()
594+
chatIds.push(chatId)
595+
const runId = generateId()
596+
const streamId = generateId()
597+
await db.insert(copilotChats).values({ id: chatId, userId, workspaceId, type: 'mothership' })
598+
await db.insert(copilotRuns).values({
599+
id: runId,
600+
executionId: generateId(),
601+
chatId,
602+
userId,
603+
workspaceId,
604+
streamId,
605+
status: 'paused_waiting_for_tool',
606+
toolExecutionVersion: 2,
607+
})
608+
const toolCallId = scopeProviderToolCallId(
609+
'workflow-call',
610+
createProviderToolCallIdentity(runId)
611+
)
612+
await upsertAsyncToolCall({ runId, toolCallId, toolName: 'run_workflow', args: {} })
613+
await db
614+
.update(copilotAsyncToolCalls)
615+
.set({ status: 'running' })
616+
.where(eq(copilotAsyncToolCalls.toolCallId, toolCallId))
617+
try {
618+
/** The dead controller's server fallback claimed the call, then died before starting it. */
619+
expect(await claimWorkflowToolExecution(toolCallId, generateId(), 'sim')).not.toBeNull()
620+
621+
expect(await claimWorkflowToolExecution(toolCallId, generateId(), 'client')).toBeNull()
622+
expect(await claimWorkflowToolExecution(toolCallId, generateId(), 'sim')).not.toBeNull()
623+
const successor = { toolCallId, runId, userId, ownerToken: 'successor' }
624+
expect(await claimToolExecution(successor)).toEqual({ outcome: 'claimed' })
625+
expect(await claimToolExecution({ ...successor, ownerToken: 'late-claimant' })).toEqual({
626+
outcome: 'existing',
627+
})
628+
expect(await claimWorkflowToolExecution(toolCallId, generateId(), 'sim')).toBeNull()
629+
expect(await storedTool(toolCallId)).toMatchObject({ attempts: 1, ownerToken: 'successor' })
630+
} finally {
631+
await db.update(copilotRuns).set({ status: 'cancelled' }).where(eq(copilotRuns.id, runId))
632+
}
633+
})
591634
})

0 commit comments

Comments
 (0)