Skip to content
31 changes: 31 additions & 0 deletions apps/sim/app/api/workflows/[id]/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ import {
import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/mothership/constants'
import { CopilotDegradedReason } from '@/lib/mothership/generated/trace-attribute-values-v1'
import { recordDegraded } from '@/lib/mothership/request/metrics'
import {
reportQueuedClientWorkflowTool,
reportSettledClientWorkflowTool,
} from '@/lib/mothership/request/tools/workflow-client-settlement'
import {
ASYNC_WORKFLOW_DEPLOYMENT_ERRORS,
type CopilotWorkflowToolBindingResult,
Expand Down Expand Up @@ -509,11 +513,25 @@ async function handleExecutePost(
)
await copilotSettlement
}
/** A bound execution reports its own outcome, so a browser that detached never strands the turn. */
const executeBoundWorkflow = async <T>(execute: () => Promise<T>): Promise<T> => {
try {
return await execute()
} finally {
await settleCopilotExecution()
if (copilotToolCallId && workflowToolClaimAcquired) {
Comment thread
waleedlatif1 marked this conversation as resolved.
await reportSettledClientWorkflowTool({
toolCallId: copilotToolCallId,
executionId,
workflowId,
}).catch((error) => {
reqLogger.warn('Could not report settled Copilot workflow execution', {
copilotToolCallId,
executionId,
error: getErrorMessage(error),
Comment thread
waleedlatif1 marked this conversation as resolved.
})
})
}
}
}

Expand Down Expand Up @@ -1297,6 +1315,19 @@ async function handleExecutePost(
trustedInitialResolvedSecretTraceProvenance,
})
executionIdClaimCommitted = asyncResult.retainExecutionClaim
if (copilotToolCallId && workflowToolClaimAcquired && asyncResult.retainExecutionClaim) {
Comment thread
waleedlatif1 marked this conversation as resolved.
await reportQueuedClientWorkflowTool({
toolCallId: copilotToolCallId,
executionId,
workflowId,
}).catch((error) => {
reqLogger.warn('Could not report queued Copilot workflow execution', {
copilotToolCallId,
executionId,
error: getErrorMessage(error),
})
})
}
return asyncResult.response
}

Expand Down
15 changes: 15 additions & 0 deletions apps/sim/lib/mothership/async-runs/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,21 @@ export async function claimWorkflowToolExecution(
)
}

/**
* Finalizes a client-bound workflow tool from its own settled execution. It
* applies only while the call is still running under that execution's claim, so
* a browser report or a background detach that landed first always wins.
*/
export async function completeClientWorkflowToolCall(
input: CompleteAsyncToolCallInput,
executionId: string
) {
return await completeClaimedAsyncToolCall(
input,
`${WORKFLOW_EXECUTION_CLAIM_PREFIX}${executionId}`
)
}

export async function releaseWorkflowToolExecutionClaim(toolCallId: string, executionId: string) {
const claimedBy = `${WORKFLOW_EXECUTION_CLAIM_PREFIX}${executionId}`
return await withDbSpan(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ interface RaceWorkflowToolClientPickupParams {
*
* After `graceMs` with no result, this competes for the same single-winner
* execution claim that `/api/workflows/[id]/execute` takes on the browser's
* behalf. Losing the claim means a browser really is running it, so we go back
* to waiting; winning it means nobody was there, so we run it in-process.
* behalf. Losing the claim means a browser started it through that route, so we
* go back to waiting: the route reports the bound execution's outcome itself when
* it settles, even if the browser has gone. Winning it means nobody was there, so
* we run it in-process.
* Because both sides contend on `claimedBy IS NULL`, the workflow can never run
* twice — a browser arriving late gets a 409 it already treats as benign.
*/
Expand Down
Loading
Loading