Skip to content

Commit cc7fc81

Browse files
committed
fix(mothership): report a browser-claimed workflow tool from its settled execution
When a browser claims a Chat workflow tool, the execute route runs the workflow and keeps running it after the browser detaches, but only the browser's confirmation completed the tool call. A tab that closed, lost its network, or dropped its pagehide beacon left the Chat turn waiting for the full client wait while the worker swept the call. The execute route now records the bound execution's structural completion itself once it settles, guarded on the call still running under that execution's claim, so a browser report or background detach that lands first is kept and nothing is delivered twice.
1 parent be50bc6 commit cc7fc81

5 files changed

Lines changed: 310 additions & 2 deletions

File tree

‎apps/sim/app/api/workflows/[id]/execute/route.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import {
9595
import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/mothership/constants'
9696
import { CopilotDegradedReason } from '@/lib/mothership/generated/trace-attribute-values-v1'
9797
import { recordDegraded } from '@/lib/mothership/request/metrics'
98+
import { reportSettledClientWorkflowTool } from '@/lib/mothership/request/tools/workflow-client-settlement'
9899
import {
99100
ASYNC_WORKFLOW_DEPLOYMENT_ERRORS,
100101
type CopilotWorkflowToolBindingResult,
@@ -509,11 +510,25 @@ async function handleExecutePost(
509510
)
510511
await copilotSettlement
511512
}
513+
/** A bound execution reports its own outcome, so a browser that detached never strands the turn. */
512514
const executeBoundWorkflow = async <T>(execute: () => Promise<T>): Promise<T> => {
513515
try {
514516
return await execute()
515517
} finally {
516518
await settleCopilotExecution()
519+
if (copilotToolCallId && workflowToolClaimAcquired) {
520+
await reportSettledClientWorkflowTool({
521+
toolCallId: copilotToolCallId,
522+
executionId,
523+
workflowId,
524+
}).catch((error) => {
525+
reqLogger.warn('Could not report settled Copilot workflow execution', {
526+
copilotToolCallId,
527+
executionId,
528+
error: getErrorMessage(error),
529+
})
530+
})
531+
}
517532
}
518533
}
519534

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,6 +1216,21 @@ export async function claimWorkflowToolExecution(
12161216
)
12171217
}
12181218

1219+
/**
1220+
* Finalizes a client-bound workflow tool from its own settled execution. It
1221+
* applies only while the call is still running under that execution's claim, so
1222+
* a browser report or a background detach that landed first always wins.
1223+
*/
1224+
export async function completeClientWorkflowToolCall(
1225+
input: CompleteAsyncToolCallInput,
1226+
executionId: string
1227+
) {
1228+
return await completeClaimedAsyncToolCall(
1229+
input,
1230+
`${WORKFLOW_EXECUTION_CLAIM_PREFIX}${executionId}`
1231+
)
1232+
}
1233+
12191234
export async function releaseWorkflowToolExecutionClaim(toolCallId: string, executionId: string) {
12201235
const claimedBy = `${WORKFLOW_EXECUTION_CLAIM_PREFIX}${executionId}`
12211236
return await withDbSpan(

‎apps/sim/lib/mothership/request/tools/workflow-client-fallback.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,10 @@ interface RaceWorkflowToolClientPickupParams {
4949
*
5050
* After `graceMs` with no result, this competes for the same single-winner
5151
* execution claim that `/api/workflows/[id]/execute` takes on the browser's
52-
* behalf. Losing the claim means a browser really is running it, so we go back
53-
* to waiting; winning it means nobody was there, so we run it in-process.
52+
* behalf. Losing the claim means a browser started it through that route, so we
53+
* go back to waiting: the route reports the bound execution's outcome itself when
54+
* it settles, even if the browser has gone. Winning it means nobody was there, so
55+
* we run it in-process.
5456
* Because both sides contend on `claimedBy IS NULL`, the workflow can never run
5557
* twice — a browser arriving late gets a 409 it already treats as benign.
5658
*/
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/**
2+
* A browser claims a Chat workflow tool, the execute route runs it, and the browser may never
3+
* report back (tab closed, network lost, beacon dropped). Runs against real PostgreSQL: the claim,
4+
* settlement, execution log lookup, guarded completion and the Chat-side waiter are production code.
5+
*/
6+
import { db } from '@sim/db'
7+
import {
8+
copilotAsyncToolCalls,
9+
copilotChats,
10+
copilotRuns,
11+
user,
12+
workflow,
13+
workflowExecutionLogs,
14+
workflowExecutionSnapshots,
15+
workspace,
16+
} from '@sim/db/schema'
17+
import { generateId } from '@sim/utils/id'
18+
import { eq, inArray } from 'drizzle-orm'
19+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
20+
import { SIM_TOOL_EXECUTION_VERSION } from '@/lib/mothership/async-runs/lifecycle'
21+
import {
22+
claimWorkflowToolExecution,
23+
completeAsyncToolCall,
24+
detachAsyncToolCall,
25+
settleClientWorkflowToolExecution,
26+
} from '@/lib/mothership/async-runs/repository'
27+
import { waitForWorkflowToolCompletion } from '@/lib/mothership/request/tools/client'
28+
import { reportSettledClientWorkflowTool } from '@/lib/mothership/request/tools/workflow-client-settlement'
29+
30+
/** Longer than the waiter's durable poll, far shorter than the hour it used to park for. */
31+
const WAIT_MS = 10_000
32+
33+
describe('settled client-claimed workflow tools', () => {
34+
const userId = generateId()
35+
const workspaceId = generateId()
36+
const workflowId = generateId()
37+
const chatId = generateId()
38+
const runId = generateId()
39+
const snapshotIds: string[] = []
40+
41+
beforeAll(async () => {
42+
const now = new Date()
43+
await db.insert(user).values({
44+
id: userId,
45+
name: 'Workflow settlement fixture',
46+
email: `${userId}@workflow-settlement.test`,
47+
emailVerified: true,
48+
createdAt: now,
49+
updatedAt: now,
50+
})
51+
await db.insert(workspace).values({
52+
id: workspaceId,
53+
name: 'Workflow settlement fixture',
54+
ownerId: userId,
55+
billedAccountUserId: userId,
56+
})
57+
await db.insert(workflow).values({
58+
id: workflowId,
59+
userId,
60+
workspaceId,
61+
name: 'Workflow settlement fixture',
62+
lastSynced: now,
63+
createdAt: now,
64+
updatedAt: now,
65+
})
66+
await db.insert(copilotChats).values({
67+
id: chatId,
68+
userId,
69+
workspaceId,
70+
type: 'mothership',
71+
conversationId: generateId(),
72+
})
73+
await db.insert(copilotRuns).values({
74+
id: runId,
75+
executionId: generateId(),
76+
chatId,
77+
userId,
78+
workspaceId,
79+
streamId: generateId(),
80+
toolExecutionVersion: SIM_TOOL_EXECUTION_VERSION,
81+
status: 'paused_waiting_for_tool',
82+
requestContext: { source: 'headless_lifecycle' },
83+
})
84+
})
85+
86+
afterAll(async () => {
87+
await db.delete(copilotChats).where(eq(copilotChats.id, chatId))
88+
await db.delete(workflowExecutionLogs).where(eq(workflowExecutionLogs.workspaceId, workspaceId))
89+
if (snapshotIds.length)
90+
await db
91+
.delete(workflowExecutionSnapshots)
92+
.where(inArray(workflowExecutionSnapshots.id, snapshotIds))
93+
await db.delete(workflow).where(eq(workflow.id, workflowId))
94+
await db.delete(workspace).where(eq(workspace.id, workspaceId))
95+
await db.delete(user).where(eq(user.id, userId))
96+
})
97+
98+
/** The execute route's durable log of one bound execution, as the Chat waiter reads it. */
99+
async function executionLog(
100+
toolCallId: string,
101+
executionId: string,
102+
status: 'completed' | 'failed' | 'cancelled'
103+
) {
104+
const snapshotId = generateId()
105+
snapshotIds.push(snapshotId)
106+
await db
107+
.insert(workflowExecutionSnapshots)
108+
.values({ id: snapshotId, stateHash: generateId(), stateData: {} })
109+
const now = new Date()
110+
await db.insert(workflowExecutionLogs).values({
111+
id: generateId(),
112+
workflowId,
113+
workspaceId,
114+
executionId,
115+
stateSnapshotId: snapshotId,
116+
level: status === 'completed' ? 'info' : 'error',
117+
status,
118+
trigger: 'copilot',
119+
startedAt: now,
120+
endedAt: now,
121+
executionData: { correlation: { copilotToolCallId: toolCallId } },
122+
})
123+
}
124+
125+
/** A run_workflow call the browser claimed through the execute route, then ran to `status`. */
126+
async function claimedAndSettled(status: 'completed' | 'failed' | 'cancelled' = 'completed') {
127+
const toolCallId = generateId()
128+
const executionId = generateId()
129+
await db.insert(copilotAsyncToolCalls).values({
130+
runId,
131+
toolCallId,
132+
toolName: 'run_workflow',
133+
args: { workflowId },
134+
status: 'running',
135+
})
136+
expect(await claimWorkflowToolExecution(toolCallId, executionId, 'client')).not.toBeNull()
137+
await executionLog(toolCallId, executionId, status)
138+
await settleClientWorkflowToolExecution(toolCallId, executionId)
139+
return { toolCallId, executionId }
140+
}
141+
142+
async function toolRow(toolCallId: string) {
143+
const [row] = await db
144+
.select()
145+
.from(copilotAsyncToolCalls)
146+
.where(eq(copilotAsyncToolCalls.toolCallId, toolCallId))
147+
return row
148+
}
149+
150+
it.each([
151+
['completed', 'success', { success: true }],
152+
['failed', 'error', { success: false }],
153+
['cancelled', 'cancelled', { success: false, reason: 'user_cancelled', cancelledByUser: true }],
154+
] as const)(
155+
'delivers a %s run to the waiting Chat turn when the browser never reports',
156+
async (logStatus, outcome, data) => {
157+
const { toolCallId, executionId } = await claimedAndSettled(logStatus)
158+
const waiting = waitForWorkflowToolCompletion({ toolCallId, workflowId, timeoutMs: WAIT_MS })
159+
160+
await reportSettledClientWorkflowTool({ toolCallId, executionId, workflowId })
161+
162+
const completion = await waiting
163+
expect(completion).toMatchObject({
164+
status: outcome,
165+
data: { ...data, workflowId, executionId },
166+
})
167+
expect(await toolRow(toolCallId)).toMatchObject({
168+
status: logStatus,
169+
claimedBy: null,
170+
result: { ...data, workflowId, executionId },
171+
})
172+
}
173+
)
174+
175+
it('keeps the browser report that landed first', async () => {
176+
const { toolCallId, executionId } = await claimedAndSettled()
177+
const reported = await completeAsyncToolCall({
178+
toolCallId,
179+
status: 'completed',
180+
result: { success: true, workflowId, executionId },
181+
})
182+
183+
await reportSettledClientWorkflowTool({ toolCallId, executionId, workflowId })
184+
185+
expect((await toolRow(toolCallId)).completedAt).toEqual(reported?.completedAt)
186+
})
187+
188+
it('keeps a background detach the browser reported on pagehide', async () => {
189+
const { toolCallId, executionId } = await claimedAndSettled()
190+
await detachAsyncToolCall(toolCallId, { preserveClaim: true })
191+
192+
await reportSettledClientWorkflowTool({ toolCallId, executionId, workflowId })
193+
194+
expect(await toolRow(toolCallId)).toMatchObject({ status: 'delivered', result: null })
195+
})
196+
197+
it('never completes a call bound to a different execution', async () => {
198+
const { toolCallId } = await claimedAndSettled()
199+
const strayExecutionId = generateId()
200+
await executionLog(toolCallId, strayExecutionId, 'completed')
201+
202+
await reportSettledClientWorkflowTool({
203+
toolCallId,
204+
executionId: strayExecutionId,
205+
workflowId,
206+
})
207+
208+
expect(await toolRow(toolCallId)).toMatchObject({ status: 'running', result: null })
209+
})
210+
})
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { createLogger } from '@sim/logger'
2+
import { completeClientWorkflowToolCall } from '@/lib/mothership/async-runs/repository'
3+
import { publishToolConfirmation } from '@/lib/mothership/persistence/tool-confirm'
4+
import {
5+
createStructuralWorkflowToolCompletionData,
6+
getWorkflowToolCompletionMessage,
7+
getWorkflowToolConfirmationStatus,
8+
} from '@/lib/mothership/tools/workflow-tools'
9+
import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state'
10+
11+
const logger = createLogger('CopilotWorkflowClientSettlement')
12+
13+
interface ReportSettledClientWorkflowToolParams {
14+
toolCallId: string
15+
executionId: string
16+
workflowId: string
17+
}
18+
19+
/**
20+
* Report a browser-claimed workflow tool's outcome from the execution it bound.
21+
*
22+
* The execute route runs the workflow on the browser's behalf and keeps running
23+
* it after the browser detaches, so the settled execution log already holds the
24+
* result; the browser's confirmation only carries a wakeup. Recording the same
25+
* structural completion here means a tab that closes, loses its network, or
26+
* drops its `pagehide` beacon no longer parks the Chat turn for the full client
27+
* wait. Whichever of this and the browser's report lands first is the one kept.
28+
*/
29+
export async function reportSettledClientWorkflowTool({
30+
toolCallId,
31+
executionId,
32+
workflowId,
33+
}: ReportSettledClientWorkflowToolParams): Promise<void> {
34+
const execution = await getTrustedWorkflowToolExecution(executionId, workflowId, toolCallId)
35+
if (!execution) {
36+
logger.warn('Settled client workflow execution has no trusted log; leaving it to the client', {
37+
toolCallId,
38+
executionId,
39+
workflowId,
40+
})
41+
return
42+
}
43+
44+
const status = getWorkflowToolConfirmationStatus(execution.status)
45+
const message = getWorkflowToolCompletionMessage(status)
46+
const data = createStructuralWorkflowToolCompletionData(status, workflowId, executionId)
47+
const completed = await completeClientWorkflowToolCall(
48+
{
49+
toolCallId,
50+
status: execution.status,
51+
result: data,
52+
error: execution.status === 'completed' ? null : message,
53+
},
54+
executionId
55+
)
56+
if (!completed) return
57+
58+
publishToolConfirmation({
59+
toolCallId,
60+
status,
61+
message,
62+
timestamp: new Date().toISOString(),
63+
data,
64+
executionId,
65+
})
66+
}

0 commit comments

Comments
 (0)